diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f45254e..4fc7893e 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.2.19" + KSADK_WEB_VERSION: "0.3.0" steps: - uses: actions/checkout@v4 @@ -59,3 +59,103 @@ jobs: - name: Audit wheel and sdist file lists run: make open-source-audit-dist + + # 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)。 + test-adk-matrix: + name: full pytest (google-adk ${{ matrix.google-adk }}) + runs-on: ubuntu-latest + env: + KSADK_WEB_VERSION: "0.3.0" + strategy: + fail-fast: false + matrix: + google-adk: ["1.34.3", "2.5.0"] + 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: Install dependencies + run: uv sync --extra all + + - name: Pin google-adk for this matrix leg + run: uv pip install "google-adk==${{ matrix.google-adk }}" + + - 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 + + # Packaging tests inspect the wheel itself; build it in each isolated ADK + # environment rather than depending on an artifact from another job. + - name: Build package artifacts + run: uv build + + # --no-sync: 防止 uv run 按 lock 重新同步、把上一步行覆盖的 adk 版本回退。 + - name: Run full test suite + run: uv run --no-sync pytest tests/ -q + + # goal-16: Windows a2a 命令 bug 回归(用户首报:"No such command 'a2a'")。 + # 在本机 macOS 无法闭环 Windows,故 CI 加 Windows runner 跑 CLI smoke: + # `agentengine a2a -h` 退出码 0,且 a2a 命令确已注册(非 try/except ImportError 静默吞)。 + test-cli-windows: + name: CLI smoke (windows-latest, a2a -h 注册回归) + runs-on: windows-latest + env: + # GitHub's non-interactive Windows output can default to cp1252 while + # KsADK's public CLI help includes Chinese text. + PYTHONUTF8: "1" + 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: Install dependencies + run: uv sync --extra all + + - name: a2a -h exits 0 + run: uv run --no-sync agentengine a2a -h + + - name: a2a command is registered (no silent ImportError swallow) + run: uv run --no-sync python -c "from ksadk.cli import _register_commands, cli; _register_commands(); assert 'a2a' in cli.commands, 'a2a command was silently swallowed'; print('a2a registered OK')" + + test-codex-native-runtime: + name: Codex native ManagedRuntime (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, 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: Install KsADK and native Codex runtime + run: uv sync --extra dev --extra codex + + - name: Import platform package, resolve binary, and start local Web + run: uv run --no-sync pytest tests/test_managed_runtime_native_smoke.py -q diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 5b0335b5..3eebf2b5 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.2.19" + default: "0.3.0" 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.2.19' }} + KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.3.0' }} 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 af92b341..92252ab9 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -6,9 +6,11 @@ on: paths: - "pyproject.toml" - "MANIFEST.in" + - "Makefile" - "ksadk/**" - "ksadk_runtime_common/**" - "scripts/open_source_audit.py" + - "scripts/verify_ksadk_web_static.py" - ".github/workflows/release-check.yml" jobs: @@ -25,9 +27,19 @@ jobs: with: python-version: "3.11" + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install dependencies run: uv sync --extra dev + - name: Sync pinned KsADK Web static assets + env: + KSADK_WEB_VERSION: "0.3.0" + run: make public-sync-ksadk-web-static + - name: Build artifacts run: uv build diff --git a/.gitignore b/.gitignore index bda8846e..328808ca 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ site/ # Legacy local UI source is owned by kingsoftcloud/ksadk-web. ksadk/server/web-ui/ +e2e-codex-agent/ diff --git a/.gitleaks.toml b/.gitleaks.toml index e77adb8b..07193f89 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -25,6 +25,18 @@ paths = [ '''tests/test_cmd_hermes\.py''', ] +# Route-manifest fixture checksums are deterministic SHA-256 test data, not +# credentials. The public CI scans every public branch's history, including the +# superseded runtime-foundation candidate where this fixture originated. +[[allowlists]] +description = "Allowlist deterministic route-manifest SHA-256 test fixture" +regexes = [ + '''ac9086d1592d69d7eecd4260c2ca4bfa06b0daaed5cdf0653c4eb409204ed84d''', +] +paths = [ + '''tests/server/test_route_manifest_parity\.py''', +] + # ---- 历史已删文档中的 OPENAI_API_KEY (疑似真 kspmas key) ---- # !!! 安全前置条件 !!! # 启用本 allowlist 之前, 必须先在金山云 kspmas 控制台 rotate (吊销并重发) 该 key, diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6dccf6..11ff3fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,44 @@ ## [Unreleased] +## [0.8.0] - 2026-07-24 + +> **Review candidate, not a published release.** This section covers the work +> merged after `0.7.0` on 2026-07-15. It does not assert that a PyPI/npm +> package, tag, or GitHub Release exists. + +### 亮点 + +- **统一 Runtime 基座**:冻结 `RuntimeEvent v1` 信封和六动词 `RuntimeAdapter`,补齐事件存储、按会话事件序号(`seq_id`)续订的订阅、共享 parser 与历史回放。ADK、LangGraph、A2A、Harness 和 Codex 新集成均以这一契约交换运行状态,而不是各自定义一套 SSE 语义。 +- **Hosted UI 进入 AG-UI + A2UI 轨道**:在不改变 OpenAI Responses 既有请求/响应语义的前提下,增加 capability 协商后的 AG-UI transport 和 A2UI activity 投影;无法协商时仍走 Responses fallback。 +- **可诊断的会话连续性**:runtime storage 成为会话状态的权威来源,补全结构化 Responses 历史投影、请求 metadata 透传、run 订阅心跳与 idle SSE 保活,刷新、续订、审批和恢复都能基于已持久化事件排查。 +- **可组合的运行形态**:加入 A2A wire 1.0 runtime、HarnessApp、CodexRuntime 和 Skill Space 路由;这些能力均保留本地/契约测试,生产环境互操作和真实凭证仍需按部署环境验收。 + +### 新增 + +- **事件与 adapter**:新增 RuntimeEvent schema、严格反序列化校验、RuntimeEventStore、session 级订阅、共享 projection/replay parser,以及 ADK/LangGraph 的 adapter contract tests。未知事件不能绕过事件边界进入 replay。 +- **AG-UI 与 A2UI**:新增 AG-UI route group、RuntimeEvent 到 activity 的投影、A2UI core/renderer/fixture viewer 和可持久化 action 记录。审批不是另一套 UI 协议,而是事件流中的受控交互状态。 +- **A2A**:新增 Agent Card、Protocol Runtime、PostgreSQL TaskStore、account + runtime 复合 owner identity、Task cancel/resume adapter、Space 内动态发现、credential provider、egress policy 与 A2A event adapter。AgentEngine 的托管 composition root 使用 Gateway 验证的五元目标绑定和受信 Card probe;它将 resume state 留在 Runtime 本地 durable storage,并为 `external_public` 提供 HTTPS-only、DNS/IP pin、禁代理、拒绝 3xx 的 NAT transport。app factory 直接装配 A2A 数据面路由;`external_vpc` 仍需单独的 VPC dialer。 +- **Harness 与 Codex**:新增声明式 HarnessApp composition root,模型/MCP/tool 配置校验和默认只读 sandbox policy;新增基于官方 app-server transport 的 CodexRuntime、生命周期 phase 映射及离线/显式 live 演示。真实 provider 凭证 E2E 不包含在本候选的发布结论中。 +- **框架与 App Factory**:新增 ADK `1.34.x`/`2.x` 兼容层及 CI matrix;server 创建改为 per-app factory/state,路由按职责拆组,WebSocket 也在请求上下文中运行。 +- **CLI 与诊断**:新增 `agentengine a2a` 和 `ksadk replay `。`replay` 只读取已持久化的 RuntimeEvent,按 `--after-seq-id` / `--before-seq-id` 定位窗口并输出 text 或 JSON transcript;它不重跑模型、工具或副作用,旧式 SessionEvent 也不在此命令的回放范围内。 +- **Skills、工具与可观测性**:新增按 `space_id` 定向的 Skill Space 消费路径,并完成 tools、memory、sandbox 和 tracing adapter 的迁移,以便 Harness/runner 在同一运行边界消费它们。 + +### 修复与性能 + +- 修复 local/hosted session history 的投影和 metadata 边界,避免刷新后漏失正文、reasoning、tool、approval 或附件状态;恢复请求会先落盘 `resuming` 状态,避免 SSE 返回与持久化状态竞态。 +- 修复 run-event 订阅缺少心跳、空闲 SSE 被中间网络断开、MCP API/delete error 行为漂移、Windows ADK 安装与 create-agent streaming 兼容问题。 +- 修复 E2B sandbox 执行硬化、workspace 编辑工具选择、Hermes 项目创建,以及 PostgreSQL/in-memory session 后端的连续性与 fail-open 行为。 +- 为 agent-scoped session/event 查询增加 covering indexes,降低多会话历史和回放场景下的数据库扫描成本。 +- 托管 A2A 在 public-egress 最终投影缺失时按关闭处理;不把 Runtime reasoning、checkpoint handle 或 resume target 写入公开 A2A Task/Message metadata,取消成功后清除 Runtime-local resume state。 + +### 兼容性、迁移与评审边界 + +- OpenAI Responses 和 Chat Completions 兼容入口仍是默认基线。AG-UI/A2UI 是可选 Hosted UI 能力,不会要求现有 Responses 客户端改协议。 +- 旧版 LangChain 连续性 / HITL 路径不再是 `0.8` 的兼容性承诺。新接入应使用 LangGraph、ADK 或 `RuntimeAdapter`;迁移时先验证 checkpoint、interrupt 和工具语义。 +- 本候选的 Python 版本为 `0.8.0`,配套 Web 候选为 `@kingsoftcloud/ksadk-web@0.3.0`。Python release workflow 已固定请求该版本,并逐文件校验 npm tarball 的 `dist-ksadk`、同步目录和 wheel 内静态资源;在 Web `0.3.0` 经受保护 npm 流程发布前,公开 Python 发布会保持阻塞,不会回退到旧版本。 +- 公开发布仍被 review/sign-off、clean-export/public preflight、真实 staging evidence 与可选的 Codex provider E2E 阻塞;本条目不构成发布批准。 + ## [0.7.0] - 2026-07-15 ### 亮点 diff --git a/Makefile b/Makefile index 20f6a1fa..b21e3372 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 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 sync-hosted-ui build-frontend build-webui sync-static webui build-wheel build-all clean-frontend # 默认目标 help: @@ -14,7 +14,7 @@ help: @echo " make test 运行测试" @echo "" @echo " \033[1;32mWeb UI 构建:\033[0m" - @echo " make sync-ksadk-web-static KSADK_WEB_VERSION=latest" + @echo " make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0" @echo " 从 @kingsoftcloud/ksadk-web npm 包同步 static" @echo " make build-frontend 同步 ksadk-web static" @echo "" @@ -267,7 +267,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 +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-status: @echo "==> internal worktree" @@ -329,20 +329,24 @@ public-sync-check: public-secret-audit: @echo "==> secret and sensitive-file audit" - @if git ls-files | grep -E '(^|/)(\.pypirc|kubeconfig|.*\.kubeconfig|id_rsa|id_ed25519)$$'; then \ - echo "❌ 发现禁止跟踪的敏感文件"; \ - exit 1; \ + @if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then \ + if git ls-files | grep -E '(^|/)(\.pypirc|kubeconfig|.*\.kubeconfig|id_rsa|id_ed25519)$$'; then \ + echo "❌ 发现禁止跟踪的敏感文件"; \ + exit 1; \ + fi; \ fi @python3 scripts/public_secret_audit.py @echo "✅ secret audit passed" public-audit: public-secret-audit @echo "==> public source audit" - @blocked=$$(git ls-files | grep -E '^(\.pypirc$$|\.zread/(wiki|site)/)' || true); \ - if [ -n "$$blocked" ]; then \ - echo "❌ blocked tracked paths:"; \ - echo "$$blocked"; \ - exit 1; \ + @if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then \ + blocked=$$(git ls-files | grep -E '^(\.pypirc$$|\.zread/(wiki|site)/)' || true); \ + if [ -n "$$blocked" ]; then \ + echo "❌ blocked tracked paths:"; \ + echo "$$blocked"; \ + exit 1; \ + fi; \ fi @python3 scripts/open_source_audit.py --target public-repo @echo "✅ public path audit passed" @@ -373,6 +377,7 @@ public-sync-ksadk-web-static: sync-ksadk-web-static public-build-check: clean-dist sync-ksadk-web-static @echo "==> build and twine check" @uv build + @$(MAKE) verify-ksadk-web-wheel-static @uv run pytest tests/test_runtime_common_packaging.py -q @uv run --extra dev python -m twine check dist/* @$(MAKE) open-source-audit-dist @@ -558,7 +563,10 @@ openclaw-build openclaw-push openclaw-size hermes-build hermes-push hermes-size: # ============================================================ STATIC_DIR := ksadk/server/static -KSADK_WEB_VERSION ?= latest +# 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 +# substituting an older npm package when that release is not visible yet. +KSADK_WEB_VERSION ?= 0.3.0 KSADK_WEB_PACKAGE ?= @kingsoftcloud/ksadk-web KSADK_WEB_TARBALL_NAME := kingsoftcloud-ksadk-web-$(patsubst v%,%,$(KSADK_WEB_VERSION)).tgz KSADK_WEB_RELEASE_URL ?= @@ -569,7 +577,10 @@ sync-ksadk-web-static: @echo "Sync KsADK Web static assets from $(KSADK_WEB_PACKAGE)@$(KSADK_WEB_VERSION)" @rm -rf "$(KSADK_WEB_CACHE_DIR)/package" @mkdir -p "$(KSADK_WEB_CACHE_DIR)" "$(STATIC_DIR)" - @if [ -n "$(KSADK_WEB_RELEASE_URL)" ]; then \ + @if [ -f "$(KSADK_WEB_CACHE_DIR)/$(KSADK_WEB_TARBALL_NAME)" ]; then \ + echo "Using cached tarball $(KSADK_WEB_TARBALL_NAME)"; \ + echo "$(KSADK_WEB_TARBALL_NAME)" > "$(KSADK_WEB_CACHE_DIR)/.tarball-name"; \ + elif [ -n "$(KSADK_WEB_RELEASE_URL)" ]; then \ echo "Using explicit KSADK_WEB_RELEASE_URL=$(KSADK_WEB_RELEASE_URL)"; \ curl -fL --retry 3 --retry-delay 2 --retry-all-errors "$(KSADK_WEB_RELEASE_URL)" -o "$(KSADK_WEB_CACHE_DIR)/$(KSADK_WEB_TARBALL_NAME)"; \ echo "$(KSADK_WEB_TARBALL_NAME)" > "$(KSADK_WEB_CACHE_DIR)/.tarball-name"; \ @@ -592,8 +603,22 @@ sync-ksadk-web-static: @rm -rf "$(STATIC_DIR)" @mkdir -p "$(STATIC_DIR)" cp -R "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk/." "$(STATIC_DIR)/" + @$(MAKE) verify-ksadk-web-static @echo "Synced KsADK Web $(KSADK_WEB_VERSION) static assets into $(STATIC_DIR)" +verify-ksadk-web-static: + @python3 scripts/verify_ksadk_web_static.py \ + --expected "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk" \ + --actual "$(STATIC_DIR)" \ + --package-root "$(KSADK_WEB_CACHE_DIR)/package" \ + --expected-version "$(patsubst v%,%,$(KSADK_WEB_VERSION))" + +verify-ksadk-web-wheel-static: + @python3 scripts/verify_ksadk_web_static.py \ + --expected "$(KSADK_WEB_CACHE_DIR)/package/dist-ksadk" \ + --wheel "$$(ls dist/ksadk-*.whl)" \ + --expected-version "$(patsubst v%,%,$(KSADK_WEB_VERSION))" + sync-hosted-ui: sync-ksadk-web-static @echo "sync-hosted-ui is deprecated; static assets now come from $(KSADK_WEB_PACKAGE)." diff --git a/README.en.md b/README.en.md index 73969840..c62b913f 100644 --- a/README.en.md +++ b/README.en.md @@ -41,6 +41,18 @@ agentengine web . --no-open

Real local Web UI demo

+## 0.8.0 Review Candidate + +`0.8.0` is an in-review candidate branch, not a published PyPI or npm release. It brings RuntimeEvent, AG-UI/A2UI, A2A, Harness, and CodexRuntime into one runtime boundary while retaining the `/v1/responses` and `/v1/chat/completions` compatibility surfaces. + +- **Hosted UI:** AG-UI/A2UI is capability-selected; the established Responses transport remains the fallback when it is unavailable. +- **Event diagnosis:** `ksadk replay ` reads persisted RuntimeEvent history and can narrow investigation with `--after-seq-id` / `--before-seq-id`. It never runs a model, tool, or approval side effect again. +- **Managed A2A (experimental):** AgentEngine composes trusted ingress through its Gateway and Space-scoped outbound clients. The first external-Agent release supports only `external_public`, governed by `Network.EnablePublicAccess` and a constrained NAT transport; `external_vpc` is not yet available. +- **Codex Managed Runtime (new):** use an `agentengine.yaml` without `agent.py` to debug Codex natively on macOS, Windows, and Linux. Direct deployment sends an inline manifest rather than a KS3 code archive; the cloud selects a pinned Linux Runtime image. +- **Framework migration:** Prefer LangGraph, Google ADK, or the `RuntimeAdapter` contract for new integrations. Legacy LangChain continuity/HITL is not a `0.8` compatibility commitment. + +Read the [Codex Managed Runtime guide](https://kingsoftcloud.github.io/ksadk-python/en/docs/framework/guides/managed-runtime/), the [Hosted UI and event replay guide](https://kingsoftcloud.github.io/ksadk-python/en/docs/framework/guides/hosted-ui-events/), the [managed A2A Runtime guide](https://kingsoftcloud.github.io/ksadk-python/en/docs/framework/guides/a2a-runtime/), the [HarnessApp guide](https://kingsoftcloud.github.io/ksadk-python/en/docs/framework/guides/harness-app/), and the [0.8.0 changelog](CHANGELOG.md). + ## Why KsADK Most agent frameworks solve how to build agents. KsADK solves how to run, debug, deploy, and observe them. @@ -64,6 +76,7 @@ Most agent frameworks solve how to build agents. KsADK solves how to run, debug, - Ecosystem Positioning: - Observability: - Cloud Deployment: +- Hosted UI and Event Replay: - Samples: ## Related Projects diff --git a/README.md b/README.md index bdb6386c..6098479a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,18 @@ agentengine web . --no-open

KsADK 真实本地 Web UI 演示

+## 0.8.0 评审候选 + +`0.8.0` 是正在评审的候选分支,不是已发布的 PyPI/npm 版本。它把 RuntimeEvent、AG-UI/A2UI、A2A、Harness 和 CodexRuntime 统一到同一运行时边界,同时保持 `/v1/responses` 和 `/v1/chat/completions` 兼容入口可用。 + +- **Hosted UI**:AG-UI/A2UI 通过能力协商启用;不能协商时继续使用既有 Responses transport。 +- **事件诊断**:`ksadk replay ` 只读回放新 RuntimeEvent 历史,可用事件序号(`seq_id`)窗口缩小排查范围,不会再次执行模型、工具或审批副作用。 +- **托管 A2A(实验性)**:AgentEngine 通过 Gateway 装配可信入站和 Space-scoped 出站 client。首期 external Agent 仅支持 `external_public`;它受 `Network.EnablePublicAccess` 控制并使用受限 NAT transport,`external_vpc` 尚不可用。 +- **Codex Managed Runtime(新增)**:使用无 `agent.py` 的 `agentengine.yaml` 在 macOS、Windows 和 Linux 原生调试 Codex;直接部署发送内联 manifest,不上传 KS3 代码包,云端选择锁定的 Linux Runtime 镜像。 +- **框架迁移**:新接入优先使用 LangGraph、Google ADK 或 `RuntimeAdapter`。旧 LangChain 连续性 / HITL 路径不属于 `0.8` 兼容性承诺。 + +详见[Codex Managed Runtime 指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/managed-runtime/)、[Hosted UI 与事件回放指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/hosted-ui-events/)、[托管 A2A Runtime 指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/a2a-runtime/)、[HarnessApp 指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/harness-app/)和 [0.8.0 更新日志](CHANGELOG.md)。 + ## 为什么需要 KsADK 大多数 Agent 框架解决“如何开发 Agent”。KsADK 解决“如何运行、调试、部署和观测 Agent”。 @@ -64,6 +76,7 @@ agentengine web . --no-open - 生态定位对比: - 可观测: - 云端部署: +- Hosted UI 与事件回放: - 样例仓库: ## 相关项目 diff --git a/README.zh-CN.md b/README.zh-CN.md index cccc1a9a..bfa97e2d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,6 +41,18 @@ agentengine web . --no-open

KsADK 真实本地 Web UI 演示

+## 0.8.0 评审候选 + +`0.8.0` 是正在评审的候选分支,不是已发布的 PyPI/npm 版本。它把 RuntimeEvent、AG-UI/A2UI、A2A、Harness 和 CodexRuntime 统一到同一运行时边界,同时保持 `/v1/responses` 和 `/v1/chat/completions` 兼容入口可用。 + +- **Hosted UI**:AG-UI/A2UI 通过能力协商启用;不能协商时继续使用既有 Responses transport。 +- **事件诊断**:`ksadk replay ` 只读回放新 RuntimeEvent 历史,可用 `--after-seq-id` / `--before-seq-id` 缩小排查窗口,不会再次执行模型、工具或审批副作用。 +- **托管 A2A(实验性)**:AgentEngine 通过 Gateway 装配可信入站和 Space-scoped 出站 client。首期 external Agent 仅支持 `external_public`;它受 `Network.EnablePublicAccess` 控制并使用受限 NAT transport,`external_vpc` 尚不可用。 +- **Codex Managed Runtime(新增)**:使用无 `agent.py` 的 `agentengine.yaml` 在 macOS、Windows 和 Linux 原生调试 Codex;直接部署发送内联 manifest,不上传 KS3 代码包,云端选择锁定的 Linux Runtime 镜像。 +- **框架迁移**:新接入优先使用 LangGraph、Google ADK 或 `RuntimeAdapter`。旧 LangChain 连续性 / HITL 路径不属于 `0.8` 兼容性承诺。 + +详见[Codex Managed Runtime 指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/managed-runtime/)、[Hosted UI 与事件回放指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/hosted-ui-events/)、[托管 A2A Runtime 指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/a2a-runtime/)、[HarnessApp 指南](https://kingsoftcloud.github.io/ksadk-python/cn/docs/framework/guides/harness-app/)和 [0.8.0 更新日志](CHANGELOG.md)。 + ## 为什么需要 KsADK 大多数 Agent 框架解决“如何开发 Agent”。KsADK 解决“如何运行、调试、部署和观测 Agent”。 @@ -64,6 +76,7 @@ agentengine web . --no-open - 生态定位对比: - 可观测: - 云端部署: +- Hosted UI 与事件回放: - 样例仓库: ## 相关项目 diff --git a/docs-site/app/[lang]/(home)/page.tsx b/docs-site/app/[lang]/(home)/page.tsx index 66762afe..b5d82fdf 100644 --- a/docs-site/app/[lang]/(home)/page.tsx +++ b/docs-site/app/[lang]/(home)/page.tsx @@ -5,7 +5,7 @@ const copy = { cn: { title: 'Kingsoft Cloud Agent Development Kit', subtitle: '金山云智能体开发套件', - desc: '构建、部署、调试、观测企业级 AI 智能体的一站式云原生框架。兼容 Google ADK、LangGraph、LangChain 与 DeepAgents,并支持一键拉起 OpenClaw 和 Hermes 运行时。', + desc: '构建、部署、调试、观测企业级 AI 智能体的一站式云原生框架。兼容 Google ADK、LangGraph、LangChain 与 DeepAgents;0.8 新增 Codex Managed Runtime、A2A 1.0 数据面、HarnessApp 与 AG-UI/A2UI 事件轨道。', cta: '阅读文档', ctaSecondary: 'GitHub 仓库', installLabel: '安装', @@ -13,7 +13,7 @@ const copy = { en: { title: 'Kingsoft Cloud Agent Development Kit', subtitle: 'Agent development kit for Kingsoft Cloud', - desc: 'A cloud-native framework to build, deploy, debug, and observe enterprise AI agents. It works with Google ADK, LangGraph, LangChain, and DeepAgents, with one-command OpenClaw and Hermes runtime launch paths.', + desc: 'A cloud-native framework to build, deploy, debug, and observe enterprise AI agents. It works with Google ADK, LangGraph, LangChain, and DeepAgents; 0.8 adds Codex Managed Runtime, an A2A 1.0 data plane, HarnessApp, and the AG-UI/A2UI event path.', cta: 'Read the docs', ctaSecondary: 'GitHub', installLabel: 'Install', diff --git a/docs-site/content/docs/cli/index.en.mdx b/docs-site/content/docs/cli/index.en.mdx index 6bdd9146..2f925b2e 100644 --- a/docs-site/content/docs/cli/index.en.mdx +++ b/docs-site/content/docs/cli/index.en.mdx @@ -36,6 +36,7 @@ agentengine web --help | `run` | run terminal loop or local API server | yes | | `web` | start local browser UI | yes | | `a2a` | expose A2A surfaces where configured | yes | +| `replay` | read and project RuntimeEvent history | yes | | `mcp` | build or manage MCP resources | depends on target | | `build` | prepare deployment artifacts | depends on target | | `deploy` | deploy to configured cloud runtime | no | @@ -59,13 +60,21 @@ Create a new project. ```bash title="bash" agentengine init my-agent -f langgraph agentengine init my-agent -f adk +agentengine init my-codex-agent -f codex agentengine init my-agent --from-agent ./existing_agent.py ``` Supported framework flags include `adk`, `langchain`, `langgraph`, -`deepagents`, `openclaw`, and `hermes`. Public examples should prefer the +`deepagents`, `openclaw`, `hermes`, and `codex`. Public examples should prefer the frameworks that can run locally without internal infrastructure. + +`agentengine init --framework codex` creates only `agentengine.yaml`, `.env`, +`requirements.txt`, and a README; it does not create `agent.py`. Its +`artifact_type` is `ManagedRuntime`: native Codex runs locally and the server +selects the cloud Runtime image. See [Codex Managed Runtime](../framework/guides/managed-runtime). + + After importing existing code, inspect `agentengine.yaml` before running. @@ -121,6 +130,22 @@ agentengine web . --model my-model agentengine web . --no-open ``` +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. + +### `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. + +```bash title="bash" +ksadk replay session_123 +ksadk replay session_123 --after-seq-id 120 --format json +agentengine replay session_123 --before-seq-id 260 +``` + +The command only reads sessions that persist RuntimeEvent v1; it does not convert legacy SessionEvent records. `--after-seq-id` is exclusive and `--before-seq-id` is an exclusive upper bound, so use both to narrow an investigation window. + ## Protocol And Integration ### `agentengine a2a` @@ -160,6 +185,24 @@ agentengine openclaw --help Use `--dry-run` where supported when documenting or reviewing deployment-shaped commands. +### Codex ManagedRuntime (New in 0.8) + +`artifact_type: ManagedRuntime` makes `agentengine build .` create a local audit +zip containing only YAML and a lock. It is not a deployment upload; direct +deployment sends an inline manifest for the server to resolve through its Runtime +catalog: + +```bash title="bash" +agentengine build . +agentengine deploy . --target serverless --dry-run +agentengine deploy . --target serverless +``` + +ManagedRuntime does not use a KS3 code package, so `--push`, `--ks3-bucket`, and +`--ks3-path` are rejected. Forcing `--mode code` also fails so a developer-machine +binary cannot enter a Linux deployment path. Use explicit `--mode container` when +you maintain the image yourself. + ### Runtime env pass-through `agentengine deploy` and `agentengine launch` support passing custom @@ -182,7 +225,7 @@ agentengine launch . --env KEY=VALUE --env-file ./runtime.env The `--env-file` you pass is for explicit runtime env injection. Real `.env` / `.env.local` files are excluded from Code, Container, and MCP build contexts; only `.env.example` / `.env.sample` / `.env.template` templates are - kept. See [Environment Variables Reference](./environment-variables). + kept. See [Environment Variables Reference](../references/environment-variables). ### `agentengine openclaw` @@ -213,7 +256,7 @@ Memory backend options (0.6.7): The `--memory-system` CLI option only exposes `openclaw_default` and `mem0`. The `lancedb` backend must be declared via a `MEMORY_BACKEND_MANIFEST` on the runtime side, not through CLI flags; see - [Environment Variables Reference](./environment-variables). + [Environment Variables Reference](../references/environment-variables). diff --git a/docs-site/content/docs/cli/index.mdx b/docs-site/content/docs/cli/index.mdx index c11929db..f8dfc957 100644 --- a/docs-site/content/docs/cli/index.mdx +++ b/docs-site/content/docs/cli/index.mdx @@ -36,6 +36,7 @@ agentengine web --help | `run` | 运行终端循环或本地 API server | 是 | | `web` | 启动本地浏览器 UI | 是 | | `a2a` | 在配置后暴露 A2A surface | 是 | +| `replay` | 只读回放 RuntimeEvent 历史 | 是 | | `mcp` | 构建或管理 MCP 资源 | 取决于目标 | | `build` | 准备部署制品 | 取决于目标 | | `deploy` | 部署到配置的云端运行时 | 否 | @@ -59,11 +60,19 @@ agentengine web --help ```bash title="bash" agentengine init my-agent -f langgraph agentengine init my-agent -f adk +agentengine init my-codex-agent -f codex agentengine init my-agent --from-agent ./existing_agent.py ``` 支持的 framework flag 包括 `adk`、`langchain`、`langgraph`、`deepagents`、 -`openclaw` 和 `hermes`。公开示例应优先使用不依赖内部基础设施就能本地运行的框架。 +`openclaw`、`hermes` 和 `codex`。公开示例应优先使用不依赖内部基础设施就能本地运行的框架。 + + +`agentengine init --framework codex` 只生成 `agentengine.yaml`、`.env`、 +`requirements.txt` 和 README,不生成 `agent.py`。它的 `artifact_type` 是 +`ManagedRuntime`;本机运行原生 Codex 子进程,云端由服务端选择 Runtime 镜像。详见 +[Codex Managed Runtime](../framework/guides/managed-runtime)。 + 导入已有代码后,先检查 `agentengine.yaml` 再运行。 @@ -118,6 +127,23 @@ agentengine web . --model my-model agentengine web . --no-open ``` +Codex ManagedRuntime 同样使用 `agentengine web . --no-open`。它会校验当前操作系统 +对应的本地 Codex 二进制;离线但未显式锁定 Runtime 版本时会给出未锁定提示。 + +### `ksadk replay` / `agentengine replay` + +从一个 session 的新 RuntimeEvent 日志生成可读 transcript 或 JSON,适合排查断线、刷新后的 +审批状态、A2UI activity 和跨 runner 事件顺序。它**不会**重新调用模型、工具或审批 action。 + +```bash title="bash" +ksadk replay session_123 +ksadk replay session_123 --after-seq-id 120 --format json +agentengine replay session_123 --before-seq-id 260 +``` + +只有 RuntimeEvent v1 已持久化的 session 可被这个命令读取;旧式 SessionEvent 不会自动转换。 +`--after-seq-id` 是开区间,`--before-seq-id` 是不含上界,可用二者缩小诊断窗口。 + ## 协议和集成 ### `agentengine a2a` @@ -156,6 +182,21 @@ agentengine openclaw --help 解释或审核部署形态命令时,在支持的地方使用 `--dry-run`。 +### Codex ManagedRuntime(0.8 新增) + +`artifact_type: ManagedRuntime` 让 `agentengine build .` 自动生成只含 YAML 与 lock 的 +本地审计 zip。它不是部署上传物;直接部署会以内联 manifest 请求服务端解析 Runtime catalog: + +```bash title="bash" +agentengine build . +agentengine deploy . --target serverless --dry-run +agentengine deploy . --target serverless +``` + +ManagedRuntime 不使用 KS3 代码包,因此不能传 `--push`、`--ks3-bucket` 或 `--ks3-path`。 +强制 `--mode code` 也会失败,避免把开发机平台二进制打入 Linux 部署路径。需要完全自管镜像时, +使用显式 `--mode container`。 + ### 运行时 env 透传 `agentengine deploy` 和 `agentengine launch` 支持向 hosted runtime 透传自定义环境变量(0.6.5): @@ -177,7 +218,7 @@ agentengine launch . --env KEY=VALUE --env-file ./runtime.env 传入的 `--env-file` 用于显式注入运行时 env。真实的 `.env` / `.env.local` 不会进入 Code、Container 或 MCP 构建上下文,构建只保留 `.env.example` / `.env.sample` / `.env.template` 模板文件。详见 - [环境变量参考](./environment-variables)。 + [环境变量参考](../references/environment-variables)。 ### `agentengine openclaw` @@ -207,7 +248,7 @@ agentengine openclaw --help `--memory-system` CLI 选项只暴露 `openclaw_default` 与 `mem0`。`lancedb` 后端需要通过 `MEMORY_BACKEND_MANIFEST` 在运行时侧声明 manifest,不走 CLI 参数;详见 - [环境变量参考](./environment-variables)。 + [环境变量参考](../references/environment-variables)。 diff --git a/docs-site/content/docs/framework/agent.mdx b/docs-site/content/docs/framework/agent.mdx deleted file mode 100644 index 2faf06fa..00000000 --- a/docs-site/content/docs/framework/agent.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 构建智能体 ---- - -构建智能体占位页 — 内容迁移阶段会补全。 diff --git a/docs-site/content/docs/framework/build-and-package.mdx b/docs-site/content/docs/framework/build-and-package.mdx deleted file mode 100644 index 340b4c2c..00000000 --- a/docs-site/content/docs/framework/build-and-package.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: 构建与打包 ---- - -构建与打包占位页 — 内容迁移阶段会补全。 diff --git a/docs-site/content/docs/framework/getting-started/configuration.en.mdx b/docs-site/content/docs/framework/getting-started/configuration.en.mdx index 631d4c55..818f551e 100644 --- a/docs-site/content/docs/framework/getting-started/configuration.en.mdx +++ b/docs-site/content/docs/framework/getting-started/configuration.en.mdx @@ -72,7 +72,7 @@ errors are not swallowed. For the full variable list and 0.6.7 reasoning / thinking-disable injection semantics, see -[Environment variables reference - Unified model policy and fallback](../reference/environment-variables). +[Environment variables reference - Unified model policy and fallback](../../references/environment-variables). ## Project Configuration diff --git a/docs-site/content/docs/framework/getting-started/configuration.mdx b/docs-site/content/docs/framework/getting-started/configuration.mdx index 94f42449..dbeed08c 100644 --- a/docs-site/content/docs/framework/getting-started/configuration.mdx +++ b/docs-site/content/docs/framework/getting-started/configuration.mdx @@ -62,7 +62,7 @@ Hermes、OpenClaw 和通用 Agent 共用同一套默认 primary / multimodal / f 业务错误和 tool 错误不会被吞掉。 完整变量列表与 0.6.7 reasoning / thinking disable 注入语义见 -[环境变量参考 - 统一模型策略与 fallback](../reference/environment-variables)。 +[环境变量参考 - 统一模型策略与 fallback](../../references/environment-variables)。 ## 项目配置 diff --git a/docs-site/content/docs/framework/getting-started/index.en.mdx b/docs-site/content/docs/framework/getting-started/index.en.mdx index 9647dd61..65865b85 100644 --- a/docs-site/content/docs/framework/getting-started/index.en.mdx +++ b/docs-site/content/docs/framework/getting-started/index.en.mdx @@ -1,15 +1,32 @@ --- -title: Getting Started +title: Start here +description: Choose a project shape first, then complete local running, debugging, and pre-deployment validation. --- -Start here to understand KsADK: positioning, architecture, core concepts, and running your first agent in minutes. +You do not need to read every concept before you start. Pick the path that +matches your project, complete the local loop, then return to architecture and +advanced capabilities when they become useful. + +## First, make one project work + + + + + + + +## Then make it maintainable + + + + + + + +## Learn the design when needed - - - - - - - + + + diff --git a/docs-site/content/docs/framework/getting-started/index.mdx b/docs-site/content/docs/framework/getting-started/index.mdx index 7a01543f..d1777cea 100644 --- a/docs-site/content/docs/framework/getting-started/index.mdx +++ b/docs-site/content/docs/framework/getting-started/index.mdx @@ -1,15 +1,31 @@ --- -title: 入门 +title: 从这里开始 +description: 先选项目形态,再完成本地运行、调试和部署前验证。 --- -从这里开始了解 KsADK:定位、架构、核心概念,再到几分钟内跑起第一个智能体。 +不要先读完所有概念才动手。选择一条与你的项目匹配的路径,跑通本地闭环后,再按需阅读 +架构和高级能力。 + +## 先跑通一个项目 + + + + + + + +## 然后让它可维护 + + + + + + + +## 需要时再理解设计 - - - - - - - + + + diff --git a/docs-site/content/docs/framework/getting-started/quickstart.en.mdx b/docs-site/content/docs/framework/getting-started/quickstart.en.mdx index 5a17dd16..8b28507b 100644 --- a/docs-site/content/docs/framework/getting-started/quickstart.en.mdx +++ b/docs-site/content/docs/framework/getting-started/quickstart.en.mdx @@ -142,7 +142,7 @@ What can this agent do? ``` If the model provider is reachable, the CLI should stream or print a response. -If it fails, check [Troubleshooting](../reference/troubleshooting#model-calls-fail). +If it fails, check [Troubleshooting](../../references/troubleshooting#model-calls-fail). ## Start The Local Web UI @@ -223,4 +223,4 @@ You now have: - Learn framework conventions in [Frameworks](../guides/frameworks). - Publish a validated project in [Deploy to Kingsoft Cloud](../guides/cloud-deployment). - Debug with the [Local Web UI](../guides/local-web-ui). -- Check commands in the [CLI Reference](../reference/cli). +- Check commands in the [CLI Reference](/en/docs/cli). diff --git a/docs-site/content/docs/framework/getting-started/quickstart.mdx b/docs-site/content/docs/framework/getting-started/quickstart.mdx index b0f6cb08..74c3ccfd 100644 --- a/docs-site/content/docs/framework/getting-started/quickstart.mdx +++ b/docs-site/content/docs/framework/getting-started/quickstart.mdx @@ -139,7 +139,7 @@ What can this agent do? ``` 如果模型 provider 可访问,CLI 会流式输出或打印响应。失败时看 -[故障排查](../reference/troubleshooting)。 +[故障排查](../../references/troubleshooting)。 ## 启动本地 Web UI @@ -205,5 +205,5 @@ rm -rf .agentengine/ - 阅读 [配置项](./configuration),理解 `.env`、YAML 和 CLI 覆盖顺序。 - 阅读 [运行时架构](../guides/runtime-architecture),理解请求如何进入 Runner。 - 阅读 [部署到金山云](../guides/cloud-deployment),把已验证的项目发布为托管 Agent。 -- 阅读 [OpenAI 兼容 API](../reference/openai-compatible-api),把本地 Agent 接到客户端。 +- 阅读 [OpenAI 兼容 API](../../references/openai-compatible-api),把本地 Agent 接到客户端。 - 阅读 [本地 Web UI](../guides/local-web-ui),了解会话、上传和工作区预览。 diff --git a/docs-site/content/docs/framework/guides/a2a-runtime.en.mdx b/docs-site/content/docs/framework/guides/a2a-runtime.en.mdx new file mode 100644 index 00000000..04539865 --- /dev/null +++ b/docs-site/content/docs/framework/guides/a2a-runtime.en.mdx @@ -0,0 +1,221 @@ +--- +title: "A2A Runtime" +description: "Use a platform-managed A2A Runtime without weakening ingress or egress boundaries." +--- + + +The managed A2A Runtime is supplied by AgentEngine. It is not enabled by a local +application simply by setting an environment variable or creating an HTTP client. + + +## Protocol compatibility and Agent Card + +Because `0.8.0` is still a release candidate, its A2A target contract is +[`a2aproject/A2A`'s `main/docs/specification.md`](https://github.com/a2aproject/A2A/blob/main/docs/specification.md). +That document makes `specification/a2a.proto` in the same repository the +normative source of the data model. KsADK pins `a2a-sdk==1.1.0`; its protobuf +fields for `AgentCard`, `AgentInterface`, `AgentCapabilities`, and +`SecurityRequirement` match the current main definitions. If upstream main +changes before release, we must first upgrade the SDK or adapt the implementation +and update the contract test—not merely change these docs. + +This is not a KsADK JSON dialect. Each `supportedInterfaces[]` entry uses `url`, +`protocolBinding`, and `protocolVersion`, and discovery uses +`/.well-known/agent-card.json`. KsADK does not emit legacy 0.3 top-level `url`, +`preferredTransport`, or `additionalInterfaces`, and does not serve the old +`/.well-known/agent.json` endpoint. + +Preview a Card locally: + +```bash title="shell" +agentengine a2a card . \ + --url https://agent.example.com \ + --name research-agent \ + --description "Answers research questions." \ + --skill research > agent-card.json +``` + +The output is the **actual minimal conforming Card produced by KsADK**, not a +hand-trimmed copy of the official documentation's full example. `version` is the +Agent's own version; `protocolVersion: "1.0"` on every interface is the A2A +major.minor protocol version (without a patch number). The official full example +also shows optional `provider`, `iconUrl`, `documentationUrl`, +`securitySchemes`, `securityRequirements`, and signatures. KsADK does not invent +those fields when it has no real values to declare. + +```json title="agent-card.json" +{ + "name": "research-agent", + "description": "Answers research questions.", + "supportedInterfaces": [ + { + "url": "https://agent.example.com/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }, + { + "url": "https://agent.example.com/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0" + } + ], + "version": "1.0.0", + "capabilities": {"streaming": true, "pushNotifications": false}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [{"id": "research", "name": "Research", "description": "Skill: research", "tags": ["research"]}] +} +``` + +The current main schema has the following core fields and KsADK behavior: + +| main-schema field | KsADK behavior | +| --- | --- | +| `name`, `description`, `version` | Produced from `a2a card` arguments or project detection | +| `supportedInterfaces` | Declares JSON-RPC then HTTP+JSON, each at protocol `1.0` | +| `capabilities` | Explicitly declares streaming and disables unimplemented push notifications | +| `defaultInputModes`, `defaultOutputModes` | The current minimal Runtime declares `text/plain` | +| `skills` | Generated from `--skill`; with no value, a non-empty `general` skill is emitted | +| Optional fields such as `securityRequirements` | Produced only when the managed Gateway/identity layer has a real contract; a local minimal Card never invents authentication claims | + +The repository test reparses the output through the pinned SDK protobuf and +asserts that no pre-main legacy Card fields escape. This is a schema gate for the +release candidate; it deliberately does not treat optional provider or security +values in an illustrative official Card as mandatory for every Agent. + +Before release, reparse it with the same official SDK; an unknown field or an +invalid type fails the check: + +```bash title="shell" +python - <<'PY' +import json +from a2a.types import AgentCard +from google.protobuf.json_format import ParseDict + +ParseDict(json.load(open("agent-card.json")), AgentCard()) +print("A2A 1.0 AgentCard schema: OK") +PY +``` + +`agentengine a2a serve .` mounts JSON-RPC and HTTP+JSON routes at the two +interfaces declared by that Card and binds only to `127.0.0.1` locally. A +managed Runtime's public discovery Card is published by the Gateway; do not use +a development-machine Card as a production discovery record. + +## Product ownership + +AgentEngine creates `AgentEngineA2ABootstrap` when it deploys a managed Runtime. +The bootstrap owns durable task, context, and resume state; Gateway-verified +inbound identity; the event outbox lifecycle; and outbound transport. Application +code should use the Space-scoped client supplied by that Runtime, or +`A2ASpaceClient.from_env()` after the platform has injected its configuration. + +Do not construct a permissive external `httpx.AsyncClient` for an external A2A +Agent. A missing managed transport fails closed with +`A2A_EGRESS_TRANSPORT_REQUIRED`. + +## A2A Center / Space debugging + +`a2a card` and `a2a serve` are local protocol-development tools. The A2A Center +(called a **Space** in the code) is the discovery and invocation plane between +AgentEngine managed Runtimes. It does not appear merely because a normal local +process starts: after deployment, the platform injects `KSADK_A2A_SPACE_IDS`, +`KSADK_A2A_CONTROL_PLANE_URL`, and a workload token. With those values, inspect +the same Space from the CLI: + +```bash title="shell" +# Discover callable Agents in a Space and filter by capability +agentengine a2a discover --space-id space-demo --skill research + +# Start an A2A Task for the returned agent_id, then inspect or cancel it +agentengine a2a call --space-id space-demo "Summarize this week's research" +agentengine a2a status --space-id space-demo +agentengine a2a cancel --space-id space-demo +``` + +These commands print platform Task summaries; the Agent Card itself retains the +standard A2A `1.0` shape shown above. If no Space is configured, the command +fails explicitly rather than silently falling back to a public direct URL. See +the [environment-variable reference](/en/docs/references/environment-variables) +for the complete variable contract. + +## Outbound access + +The first managed release supports `external_public` Agents over the Runtime's NAT +path. `Network.EnablePublicAccess` is the only public-egress policy input; the +platform projects its effective value to `KSADK_A2A_ENABLE_PUBLIC_EGRESS`. When it +is disabled, external-public calls are rejected before credentials are resolved. + +If that projection is absent, KsADK treats it as disabled. `CreateAgent` may +default the resource field to enabled, but a Runtime uses only the effective +value that the deployment layer explicitly calculates and injects. + +The injected transport is HTTPS-only, performs a fresh DNS check for each +operation, connects to the verified IP while retaining the hostname for TLS, +disables environment proxies, and rejects redirects. It limits response size and +parsing depth before A2A payloads are processed. + +`external_vpc` is not available in the first managed release. It requires a +platform-injected VPC dialer and fails with `A2A_VPC_EGRESS_DIALER_REQUIRED`; it +must not fall back to the public NAT path. + +## Inbound access and recovery + +Managed inbound A2A routes accept only identity verified by AgentEngine Gateway. +The Runtime checks the verified account, tenant, target Agent, target Runtime, and +target A2A registration before the handler receives a request. The Runtime-local +AgentCard is also restricted to a trusted Gateway or server probe; public discovery +uses the Gateway-published Card instead. + +Checkpoint handles and resume targets remain in Runtime-local durable storage. They +are never included in public A2A Task or Message metadata. Start and stop the +bootstrap with the Runtime application lifespan so durable stores and the shared +event dispatcher are ready before traffic is served. + +A managed Runtime does not write its internal reasoning events as A2A artifacts. +Upstream callers receive only protocol task state, response content, and allowed +artifacts—not model-internal reasoning. + +An inbound-only Runtime can disable outbound operation. It then needs no control +plane, hosted HTTP client, or event outbox, and `client_for_space()` rejects +explicitly. A standalone `A2ASpaceClient` owns its background dispatcher: call +`await client.aclose()` when finished, or use `async with`. Clients created by a +bootstrap are closed by the Runtime lifespan instead. + +## Local Cross-Framework Reasoning Streams + +`agentengine a2a serve` is a local development command that binds to +`127.0.0.1` by default. It writes explicit runner `thinking` / `reasoning.*` +events to a separate `reasoning` +artifact whose Parts carry `adk_thought=true`. It does not reinterpret ordinary +status text as reasoning. Use `--no-include-reasoning` when local A2A callers +must not receive that content. If you explicitly bind to a public or LAN +address such as `--host 0.0.0.0`, the service becomes reachable through every +network interface. The local development endpoint does not configure +authentication, TLS, or network policy for you, so restrict access with trusted +callers and a firewall. + +A LangGraph orchestrator can forward the remote typed events directly into its +custom stream: + +```python +from langgraph.config import get_stream_writer +from ksadk.a2a import stream_a2a_agent_to_writer + +async def call_remote(state): + output = await stream_a2a_agent_to_writer( + "http://127.0.0.1:8094", + state["query"], + writer=get_stream_writer(), + ) + return {"result": output} +``` + +The helper is independent of the remote framework, so ADK↔ADK, +ADK↔LangGraph, and LangGraph↔LangGraph use the same protocol path. It preserves +the actual `thinking` / `text` order emitted by the remote agent, deduplicates +artifact snapshots, reconciles authoritative replacements, and returns response +text only. It never invents alternation that the remote agent did not emit. The +legacy `stream_a2a_agent()` API yields strings only and cannot encode replacement +semantics; use typed events or the writer helper when authoritative snapshots +must be reconciled. diff --git a/docs-site/content/docs/framework/guides/a2a-runtime.mdx b/docs-site/content/docs/framework/guides/a2a-runtime.mdx new file mode 100644 index 00000000..726b7e9e --- /dev/null +++ b/docs-site/content/docs/framework/guides/a2a-runtime.mdx @@ -0,0 +1,171 @@ +--- +title: "A2A Runtime" +description: "在不削弱入站和出站安全边界的前提下使用平台托管的 A2A Runtime。" +--- + + +托管 A2A Runtime 由 AgentEngine 提供。设置环境变量或创建一个 HTTP client 并不会让本地应用具备这项能力。 + + +## 协议兼容性与 Agent Card + +`0.8.0` 仍是候选版本,因此 A2A 以 +[`a2aproject/A2A` 的 `main/docs/specification.md`](https://github.com/a2aproject/A2A/blob/main/docs/specification.md) +为目标契约;该文档明确以同仓的 `specification/a2a.proto` 作为规范性数据模型来源。KsADK +固定 `a2a-sdk==1.1.0`,其 `AgentCard`、`AgentInterface`、`AgentCapabilities` 和 +`SecurityRequirement` protobuf 字段已与当前 main 的这些定义对齐。发布前若上游 main 再变更, +必须先升级 SDK 或适配实现并更新契约测试,不能只改文档。 + +这不是 KsADK 自定义 JSON 方言:`supportedInterfaces[]` 中的每一项都使用 `url`、 +`protocolBinding`、`protocolVersion`,发现地址是 `/.well-known/agent-card.json`。不输出旧 +0.3 的顶层 `url`、`preferredTransport` 或 `additionalInterfaces` 字段,也不提供旧 +`/.well-known/agent.json` 服务端路由。 + +先在本机预览 Card: + +```bash title="shell" +agentengine a2a card . \ + --url https://agent.example.com \ + --name research-agent \ + --description "Answers research questions." \ + --skill research > agent-card.json +``` + +输出是 KsADK **实际生成的最小合规 Card**,不是把官方文档中的完整示例裁剪后手写出来的 JSON。 +`version` 是 Agent 自己的版本;每项 `protocolVersion: "1.0"` 才是 A2A 的 +major.minor 协议版本(不包含 patch)。官方完整示例中出现的 `provider`、`iconUrl`、 +`documentationUrl`、`securitySchemes`、`securityRequirements` 和 signatures 都是可选的扩展 +信息;没有真实值时 KsADK 不会伪造它们。 + +```json title="agent-card.json" +{ + "name": "research-agent", + "description": "Answers research questions.", + "supportedInterfaces": [ + { + "url": "https://agent.example.com/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }, + { + "url": "https://agent.example.com/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0" + } + ], + "version": "1.0.0", + "capabilities": {"streaming": true, "pushNotifications": false}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [{"id": "research", "name": "Research", "description": "Skill: research", "tags": ["research"]}] +} +``` + +当前 main 要求的 Card 核心字段及 KsADK 的对应关系如下: + +| main schema 字段 | KsADK 行为 | +| --- | --- | +| `name`、`description`、`version` | 从 `a2a card` 参数或项目检测结果生成 | +| `supportedInterfaces` | 按偏好顺序声明 JSON-RPC 与 HTTP+JSON,且每项标明 `1.0` | +| `capabilities` | 明确声明 streaming,明确关闭未实现的 push notification | +| `defaultInputModes`、`defaultOutputModes` | 当前最小 Runtime 声明 `text/plain` | +| `skills` | 由 `--skill` 生成;未传时生成 `general`,不会输出空列表 | +| `securityRequirements` 等可选字段 | 由托管 Gateway/身份层有真实契约时再生成;本地最小 Card 不杜撰认证声明 | + +仓库测试会将生成结果重新解析为 pinned SDK 的 protobuf,并断言它不包含 main 之前版本的 +Card 字段。这是发布候选的 schema 门禁;它不把某个演示 Card 的可选 provider 或认证值误当成 +所有 Agent 都必须携带的字段。 + +发布前可用同一官方 SDK 重新解析它;未识别字段或错误类型会失败: + +```bash title="shell" +python - <<'PY' +import json +from a2a.types import AgentCard +from google.protobuf.json_format import ParseDict + +ParseDict(json.load(open("agent-card.json")), AgentCard()) +print("A2A 1.0 AgentCard schema: OK") +PY +``` + +`agentengine a2a serve .` 会在该 Card 声明的两个标准接口挂载 JSON-RPC 与 HTTP+JSON +路由;本机开发只监听 `127.0.0.1`。托管 Runtime 的公开发现 Card 由 Gateway 发布, +不要把开发机 Card 当作生产 discovery 记录。 + +## 产品装配边界 + +AgentEngine 在部署托管 Runtime 时创建 `AgentEngineA2ABootstrap`。它负责 durable task、context、resume state,Gateway 验证的入站身份,event outbox 生命周期,以及出站 transport。应用代码应使用 Runtime 提供的 Space-scoped client,或在平台注入配置后调用 `A2ASpaceClient.from_env()`。 + +不要为 external A2A Agent 自行构造宽松的 `httpx.AsyncClient`。缺少平台受控 transport 时会以 `A2A_EGRESS_TRANSPORT_REQUIRED` fail-closed。 + +## A2A Center / Space 调试 + +`a2a card` 和 `a2a serve` 是本地协议开发工具;A2A Center(代码中称为 **Space**)是 +AgentEngine 托管运行时之间的发现与调用平面。它不会在普通本地进程中凭空出现:部署后平台会注入 +`KSADK_A2A_SPACE_IDS`、`KSADK_A2A_CONTROL_PLANE_URL` 和 workload token。拿到这些注入后,可用 +CLI 直接检查同一个 Space: + +```bash title="shell" +# 发现一个 Space 中可调用的 Agent,并按能力筛选 +agentengine a2a discover --space-id space-demo --skill research + +# 向返回的 agent_id 发起一个 A2A Task,随后查询或取消它 +agentengine a2a call --space-id space-demo "总结本周研究" +agentengine a2a status --space-id space-demo +agentengine a2a cancel --space-id space-demo +``` + +这些命令输出的是平台 Task 摘要;Agent Card 本身仍遵循前一节的 A2A `1.0` 标准格式。Space +未配置时命令会明确报错,而不会偷偷改为直连公网地址。完整变量说明见 +[环境变量参考](/cn/docs/references/environment-variables)。 + +## 出站访问 + +首期托管版本通过 Runtime NAT 路径支持 `external_public` Agent。`Network.EnablePublicAccess` 是唯一的 public egress 策略输入,平台将其最终值投影为 `KSADK_A2A_ENABLE_PUBLIC_EGRESS`。关闭时,在解析凭据前拒绝 external-public 调用。 + +若该投影缺失,KsADK 按关闭处理。`CreateAgent` 的资源缺省值可以是开启,但 Runtime 只使用部署层已计算并显式注入的最终值。 + +注入的 transport 仅允许 HTTPS;每次操作重新校验 DNS;连接到已校验 IP 但保留 TLS hostname;禁用环境代理并拒绝重定向。在处理 A2A payload 前还会限制响应大小和解析深度。 + +首期不支持 `external_vpc`。它必须使用平台注入的 VPC dialer,未提供时返回 `A2A_VPC_EGRESS_DIALER_REQUIRED`,绝不能回退到 public NAT 路径。 + +## 入站与恢复 + +托管入站 A2A route 只接受 AgentEngine Gateway 验证的身份。请求进入 handler 前,Runtime 会检查 account、tenant、target Agent、target Runtime 和 target A2A registration。Runtime 本地 AgentCard 同样只允许可信 Gateway 或 server probe 获取;公网 discovery 应使用 Gateway 发布的 Card。 + +Checkpoint handle 和 resume target 保留在 Runtime 本地 durable storage,绝不出现在公开 A2A Task 或 Message metadata 中。应通过 Runtime application lifespan 启动和停止 bootstrap,确保 durable store 与共享 event dispatcher 在接流量前就绪。 + +托管 Runtime 不会把内部 reasoning event 写成 A2A artifact;上游只会收到协议允许的任务状态、正文与 artifact,不会收到模型的内部推理内容。 + +只承载入站协议的 Runtime 可以禁用 outbound,此时无需注入 control plane、hosted HTTP client 或 event outbox,`client_for_space()` 会明确拒绝。独立创建的 `A2ASpaceClient` 自己拥有后台 dispatcher:使用完请 `await client.aclose()`,或用 `async with`;由 bootstrap 创建的 client 则由 Runtime lifespan 统一关闭。 + +## 本地跨框架 reasoning 流 + +`agentengine a2a serve` 面向本地调试,默认只监听 `127.0.0.1`,并会把 runner 明确产出的 +`thinking` / `reasoning.*` 事件写成独立的 `reasoning` artifact,并为 Part +添加 `adk_thought=true`。它不会把普通 status 文本伪装成 reasoning。若不希望 +本地 A2A 调用方看到这些内容,使用 `--no-include-reasoning`。若显式改为公网或 +局域网监听地址(例如 `--host 0.0.0.0`),服务会暴露给所有可达网卡;本地开发 +端点默认没有替你配置认证、TLS 或网络访问策略,请先确认调用方可信并配置防火墙。 + +LangGraph 编排方可以把远端 typed events 直接接入 custom stream: + +```python +from langgraph.config import get_stream_writer +from ksadk.a2a import stream_a2a_agent_to_writer + +async def call_remote(state): + output = await stream_a2a_agent_to_writer( + "http://127.0.0.1:8094", + state["query"], + writer=get_stream_writer(), + ) + return {"result": output} +``` + +helper 与远端实现框架无关,因此 ADK↔ADK、ADK↔LangGraph 和 +LangGraph↔LangGraph 共用同一协议路径。它会保留远端实际发出的 +`thinking` / `text` 顺序,处理 artifact 快照去重及权威替换,返回值只包含正文; +不会补造远端没有发送的“交替思考”。旧的 `stream_a2a_agent()` 仅返回字符串增量, +无法表达替换语义;需要正确处理权威快照时请使用 typed events 或上面的 writer helper。 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 c2b28071..d2d1349c 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 @@ -52,7 +52,7 @@ agentengine --dry-run deploy . 1. **clean-dist** clears the previous `dist/` - 2. **sync-ksadk-web-static** fetches the latest UI static assets + 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 @@ -93,7 +93,7 @@ Editable UI source belongs to `ksadk-web`. 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 -`@kingsoftcloud/ksadk-web@latest`; pin a version via the `ksadk_web_version` +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 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 8c20b7a2..8562ed7b 100644 --- a/docs-site/content/docs/framework/guides/build-and-package.mdx +++ b/docs-site/content/docs/framework/guides/build-and-package.mdx @@ -77,7 +77,7 @@ agentengine --dry-run deploy . Python 包可包含 `agentengine web` 需要的静态 UI 产物;可编辑 UI 源码属于 `ksadk-web`。 -正式 PyPI 发布走 `.github/workflows/publish-pypi.yml`,由 GitHub Release `published` 事件或 `workflow_dispatch` 触发;workflow 先 `make sync-ksadk-web-static`(默认拉取 `@kingsoftcloud/ksadk-web@latest`,可通过 `ksadk_web_version` input 指定版本),再 `make public-preflight`,最后通过 OIDC Trusted Publishing 上传,不依赖长期 PyPI token。 +正式 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 内静态文件的完整路径与内容哈希;任一不一致都会拒绝构建。 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/cloud-deployment.en.mdx b/docs-site/content/docs/framework/guides/cloud-deployment.en.mdx index e3e31bc5..2eb02e3f 100644 --- a/docs-site/content/docs/framework/guides/cloud-deployment.en.mdx +++ b/docs-site/content/docs/framework/guides/cloud-deployment.en.mdx @@ -18,6 +18,7 @@ Agent on Kingsoft Cloud. | Target | Use it when | Default artifact | | --- | --- | --- | | `serverless` | You want the fastest hosted deployment for a standard Agent | `Code`, built and uploaded to KS3 automatically | +| `serverless` + `artifact_type: ManagedRuntime` | A YAML-driven Codex agent needs a platform-maintained Runtime image | ManagedRuntime; inline manifest, no KS3 code package and no Docker | | `serverless --artifact-type Container` | The code package is large or needs system dependencies / a custom image | Container; requires Docker and a registry | | `kce --artifact-type Container` | You already operate a KCE cluster, namespace, and registry | Container | @@ -34,16 +35,19 @@ deployment. agentengine --version ``` -2. Your project needs a working `agentengine.yaml`, entry point, and dependency - manifest. Test it first: +2. A code project needs a working `agentengine.yaml`, entry point, and dependency + manifest. A Codex ManagedRuntime has no entry point; it needs explicit + `framework: codex`, `artifact_type: ManagedRuntime`, and `runtime.name`. + Test locally first: ```bash title="shell" agentengine run . -i ``` -3. Prepare Kingsoft Cloud AK/SK credentials with permission for AgentEngine, - KS3, and the selected region. Container deployment also needs a registry you - can push to and a working local Docker installation. +3. Prepare Kingsoft Cloud AK/SK credentials with permission for AgentEngine and + the selected region. Code deployment also needs KS3 permission. Container + deployment also needs a registry you can push to and a working local Docker + installation. ManagedRuntime uses neither a KS3 code package nor local Docker. 4. Never commit AK/SK credentials, model keys, registry passwords, `.env`, `prod.env`, or `.agentengine/`. Commit only placeholder values in @@ -84,6 +88,40 @@ OPENAI_MODEL_NAME= for the local deployment client and must not be forwarded as runtime env. +## Deploy Codex ManagedRuntime (new in 0.8) + +Codex uses one `agentengine.yaml` and no `agent.py`. When `runtime.version` is +locked explicitly, the CLI uses it directly. When omitted, it asks the server +Runtime catalog for a default; a disabled catalog fails instead of guessing a +version or falling back to CodeBuilder. + +```yaml title="agentengine.yaml" +name: my-codex-agent +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime +runtime: + name: codex + version: "0.144.4" +model: kimi-k3 +prompt: | + You are a coding assistant. +``` + +Inspect the request first, then deploy: + +```bash title="shell" +agentengine deploy . --target serverless --region cn-beijing-6 --dry-run +agentengine deploy . --target serverless --region cn-beijing-6 --env-file ./prod.env +``` + +This sends an inline normalized manifest, Runtime name, version, and SHA-256. Do +not pass `--ks3-path`, and do not use `--push` or `--ks3-bucket` for a +ManagedRuntime; those Code-path options are rejected. Record the actual Runtime +version and image digest returned by deployment. See +[YAML Is the Agent: Codex](../tutorials/best-practices/codex-yaml-agent) for +the native-local workflow. + ## Deploy To Serverless Start with a dry run to inspect the target, region, artifact type, network, and @@ -208,6 +246,8 @@ pass `--enable-public-access` or `--disable-public-access`. | VPC validation fails | Supply `--vpc-id`, `--subnet-id`, and `--security-group-id` together. Availability zone is optional and cannot replace them. | | Container build fails | Confirm Docker is running, the registry is reachable, and registry credentials match the registry type. | | Code package is too large | Remove unnecessary dependencies first; then use `--artifact-type Container` when an image is justified. | +| ManagedRuntime catalog is disabled or the version is unknown | Set an explicit `runtime.version` or have the platform configure that Runtime catalog; it does not fall back to Code. | +| ManagedRuntime rejects a KS3 option | Remove `--push`, `--ks3-bucket`, and `--ks3-path`; deploy directly with `agentengine deploy .`. | | Hosted runtime cannot call the model | Confirm `prod.env` was passed with `--env-file` and its variable names match the model provider used by the Agent. | diff --git a/docs-site/content/docs/framework/guides/cloud-deployment.mdx b/docs-site/content/docs/framework/guides/cloud-deployment.mdx index 0849e38a..34a8c190 100644 --- a/docs-site/content/docs/framework/guides/cloud-deployment.mdx +++ b/docs-site/content/docs/framework/guides/cloud-deployment.mdx @@ -16,6 +16,7 @@ description: 从本地项目、云账号配置到托管 Agent 验证的完整部 | 目标 | 适用场景 | 默认制品 | | --- | --- | --- | | `serverless` | 最快把标准 Agent 部署为托管服务 | `Code`,自动构建并上传到 KS3 | +| `serverless` + `artifact_type: ManagedRuntime` | YAML 驱动的 Codex Agent,需要平台维护的 Runtime 镜像 | ManagedRuntime,内联 manifest,不上传 KS3 代码包、不需要 Docker | | `serverless --artifact-type Container` | 代码包体积较大,或依赖系统库 / 自定义镜像 | Container,需 Docker 和镜像仓库 | | `kce --artifact-type Container` | 已有 KCE 集群、命名空间和镜像仓库 | Container | @@ -30,14 +31,17 @@ description: 从本地项目、云账号配置到托管 Agent 验证的完整部 agentengine --version ``` -2. 本地项目必须有可运行的 `agentengine.yaml`、入口文件和依赖清单。部署前先运行: +2. 代码项目必须有可运行的 `agentengine.yaml`、入口文件和依赖清单。Codex ManagedRuntime + 没有入口文件;它必须有显式 `framework: codex`、`artifact_type: ManagedRuntime` 和 + `runtime.name`。部署前先运行本地验证: ```bash title="shell" agentengine run . -i ``` -3. 准备金山云 AK/SK,并确保该账号有 AgentEngine、KS3 和目标区域的资源权限。Container 部署还需要 - 可推送的镜像仓库权限;本机需要可用的 Docker。 +3. 准备金山云 AK/SK,并确保该账号有 AgentEngine 和目标区域的资源权限。Code 部署还需要 KS3 + 权限;Container 部署还需要可推送的镜像仓库权限和本机 Docker。ManagedRuntime 不使用 KS3 + 代码包或本机 Docker。 4. 不要把 AK/SK、模型 key 或镜像仓库密码提交到 Git。`.env`、`prod.env` 和 `.agentengine/` 应加入 `.gitignore`,仓库中只保留 `.env.example`。 @@ -76,6 +80,37 @@ OPENAI_MODEL_NAME= env 透传;模型 key 是否需要注入取决于你的 Agent 和模型 provider。 +## 部署 Codex ManagedRuntime(0.8 新增) + +Codex 使用唯一的 `agentengine.yaml`,没有 `agent.py`。`runtime.version` 显式锁定时, +CLI 直接使用该版本;省略时会先请求服务端 Runtime catalog 的默认值,catalog 未启用则失败, +不会猜测版本或回退到 CodeBuilder。 + +```yaml title="agentengine.yaml" +name: my-codex-agent +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime +runtime: + name: codex + version: "0.144.4" +model: kimi-k3 +prompt: | + 你是一个编码助手。 +``` + +先审查部署请求,再执行: + +```bash title="shell" +agentengine deploy . --target serverless --region cn-beijing-6 --dry-run +agentengine deploy . --target serverless --region cn-beijing-6 --env-file ./prod.env +``` + +这里发送的是内联规范化 manifest、Runtime 名称、版本和 SHA-256。不要传 `--ks3-path`,也不要为 +ManagedRuntime 使用 `--push` 或 `--ks3-bucket`;这些 Code 路径参数会被拒绝。返回值应记录实际 +Runtime 版本与镜像 digest。完整本机实践见 +[YAML 即 Agent:Codex](../tutorials/best-practices/codex-yaml-agent)。 + ## 一次性部署到 Serverless 先 dry run,确认项目名、区域、网络、制品类型和运行时 env: @@ -198,6 +233,8 @@ agentengine dashboard open | VPC 参数校验失败 | 同时传入 `--vpc-id`、`--subnet-id`、`--security-group-id`;可用区可选,不能替代这三个 ID。 | | Container 构建失败 | 确认 Docker 可运行,`--registry` 可访问,并按仓库类型配置 KCR 凭证。 | | Code 包过大 | 先移除不必要依赖;仍无法满足时改用 `--artifact-type Container`。 | +| ManagedRuntime catalog 未配置或版本未知 | 设置显式 `runtime.version`,或请平台配置对应 Runtime catalog;它不会回退到 Code。 | +| ManagedRuntime 提示 KS3 参数不允许 | 移除 `--push`、`--ks3-bucket` 和 `--ks3-path`;直接执行 `agentengine deploy .`。 | | 托管运行时找不到模型配置 | 检查 `prod.env` 是否通过 `--env-file` 传入,并确认其中的变量名符合 Agent 使用的模型 provider。 | diff --git a/docs-site/content/docs/framework/guides/frameworks.en.mdx b/docs-site/content/docs/framework/guides/frameworks.en.mdx index 38cc4f21..062999d8 100644 --- a/docs-site/content/docs/framework/guides/frameworks.en.mdx +++ b/docs-site/content/docs/framework/guides/frameworks.en.mdx @@ -2,9 +2,10 @@ title: "Frameworks" --- -KsADK supports common Python agent framework families through runtime adapters. -The public contract is simple: expose an agent object, declare the framework, and -run the project through `agentengine`. +KsADK supports common Python agent framework families through runtime adapters +and also supports a declarative managed Runtime. Code frameworks expose an agent +object, declare the framework, and run through `agentengine`; Codex is declared +by an explicit YAML manifest and does not import a user Python agent. ## Supported Families @@ -14,6 +15,29 @@ run the project through `agentengine`. | LangGraph | `ksadk[langgraph]` | `root_agent = graph.compile()` | | LangChain | `ksadk[langchain]` or framework dependencies | `root_agent = prompt | llm | parser` | | DeepAgents | `ksadk[deepagents]` | `root_agent = create_deep_agent(...)` | +| Codex ManagedRuntime (new in 0.8) | `ksadk[codex]` | YAML `model` / `prompt`; no `agent.py` | + +## Codex ManagedRuntime + +Codex is the declarative “YAML is the agent” path. Its `model` and `prompt` in +`agentengine.yaml` define the agent, while `runtime.name/version` define the +shared local/cloud Runtime version boundary. It starts the official native Codex +subprocess locally and the Runtime catalog selects a Linux image in the cloud; it +does not use CodeBuilder or copy a developer-machine binary. + +```yaml title="agentengine.yaml" +framework: codex +artifact_type: ManagedRuntime +runtime: + name: codex + version: "0.144.4" +model: gpt-5.1-codex +prompt: | + You are a coding assistant. +``` + +Start with the [YAML Is the Agent: Codex best practice](../tutorials/best-practices/codex-yaml-agent). +Do not add `entry_point`, `agent_variable`, or a placeholder `agent.py` to this project. ## Framework Runtime Architecture @@ -229,6 +253,7 @@ Each runner keeps the framework's native execution model: | LangGraph | imports the configured graph object | graph `invoke` / `ainvoke` / event stream | | LangChain | imports the configured runnable, chain, or callable | runnable/callable invoke and stream | | DeepAgents | reuses the LangGraph runner path | compiled graph execution | +| Codex | reads a declarative manifest without importing a user Agent object | official Codex app-server subprocess | The adapters normalize the result into `{"output": ...}` for non-streaming calls and semantic chunks for streaming calls. They do not rewrite the user's agent diff --git a/docs-site/content/docs/framework/guides/frameworks.mdx b/docs-site/content/docs/framework/guides/frameworks.mdx index a30abe0f..8dbba3ca 100644 --- a/docs-site/content/docs/framework/guides/frameworks.mdx +++ b/docs-site/content/docs/framework/guides/frameworks.mdx @@ -2,8 +2,9 @@ title: "框架接入" --- -KsADK 通过运行时适配器接入常见 Python Agent 框架。公开契约很简单: -暴露一个 Agent 对象,声明框架类型,然后用 `agentengine` 运行项目。 +KsADK 通过运行时适配器接入常见 Python Agent 框架,也支持声明式托管 Runtime。代码框架 +暴露 Agent 对象、声明框架类型,再用 `agentengine` 运行项目;Codex 则由一个显式 YAML +manifest 声明,不导入用户 Python Agent。 ## 支持的框架族 @@ -13,6 +14,27 @@ KsADK 通过运行时适配器接入常见 Python Agent 框架。公开契约很 | LangGraph | `ksadk[langgraph]` | `root_agent = graph.compile()` | | LangChain | `ksadk[langchain]` 或框架依赖 | `root_agent = prompt | llm | parser` | | DeepAgents | `ksadk[deepagents]` | `root_agent = create_deep_agent(...)` | +| Codex ManagedRuntime(0.8 新增) | `ksadk[codex]` | YAML `model` / `prompt`;无 `agent.py` | + +## Codex ManagedRuntime + +Codex 是 YAML 即 Agent 的声明式路径:`agentengine.yaml` 中的 `model` 与 `prompt` 定义 +Agent,`runtime.name/version` 定义本机与云端共同的 Runtime 版本边界。它本机启动官方 Codex +子进程,云端由 Runtime catalog 选择 Linux 镜像;不走 CodeBuilder,也不会复制开发机二进制。 + +```yaml title="agentengine.yaml" +framework: codex +artifact_type: ManagedRuntime +runtime: + name: codex + version: "0.144.4" +model: gpt-5.1-codex +prompt: | + 你是一个编码助手。 +``` + +从[最佳实践:YAML 即 Agent:Codex](../tutorials/best-practices/codex-yaml-agent) +开始;不要为这个项目添加 `entry_point`、`agent_variable` 或占位 `agent.py`。 ## 框架运行时架构 @@ -176,6 +198,7 @@ KsADK 优先读取显式项目配置。如果没有配置文件,再尝试常 | LangGraph | 导入配置的 graph 对象 | graph `invoke` / `ainvoke` / 事件流 | | LangChain | 导入配置的 runnable、chain 或 callable | runnable/callable invoke 与 stream | | DeepAgents | 复用 LangGraph runner 路径 | 编译 graph 执行 | +| Codex | 读取声明式 manifest,不导入用户 Agent 对象 | 官方 Codex app-server 子进程 | 适配器会把非流式结果归一成 `{"output": ...}`,把流式结果归一成语义 chunk。它们不会把用户 Agent 改写成另一种框架。 diff --git a/docs-site/content/docs/framework/guides/harness-app.en.mdx b/docs-site/content/docs/framework/guides/harness-app.en.mdx new file mode 100644 index 00000000..881bba66 --- /dev/null +++ b/docs-site/content/docs/framework/guides/harness-app.en.mdx @@ -0,0 +1,72 @@ +--- +title: HarnessApp +description: A minimal declarative composition root, new in 0.8, for assembling a unified Runtime data plane from YAML. +--- + + +HarnessApp is a runnable minimal composition root, not a complete Agent +configuration language. It does not promise its own `init`, hosted deployment, +or plugin ecosystem. Validate production credentials, real MCPs, and deployment +interoperability in the target environment. + + +HarnessApp maps a strictly validated YAML file to a `RuntimeAdapter` and a +FastAPI data plane. It reuses Responses, OpenAI-compatible API, session, and +health routes without copying a global application or silently installing a +control plane. + +## Minimal application + +Create `harness.yaml`: + +```yaml title="harness.yaml" +model: my-model +prompt: | + You are a concise coding assistant. +runtime: yaml +sandbox: + read_only: true +``` + +Create an ASGI entrypoint: + +```python title="app.py" +from ksadk.harness import HarnessApp + +app = HarnessApp.from_yaml("harness.yaml").build_app() +``` + +Run it locally with your OpenAI-compatible provider variables: + +```bash title="shell" +export OPENAI_API_KEY= +export OPENAI_BASE_URL=https://api.example.com/v1 +uvicorn app:app --host 127.0.0.1 --port 8080 +``` + +`runtime: yaml` uses the Harness YAML runner. `runtime: codex` uses the native +Codex runner, so it also needs `ksadk[codex]` and the same local provider setup +as [Codex Managed Runtime](./managed-runtime). + +## Supported YAML surface + +| Field | Behavior | +| --- | --- | +| `model` | required default model; a single call can override it only through `StartRequest`. | +| `prompt` | required developer instruction. | +| `runtime` | `yaml` (default) or `codex`. | +| `mcp_tools` | optional MCP tools; each item accepts `name`, `url`, `api_key`, `tool_filter`, and `tool_name_prefix`. Never commit a real `api_key` in YAML. | +| `sandbox.read_only` | defaults to and currently must be `true`; a write policy is rejected explicitly. | + +`memory`, `knowledge`, `workflow`, `tracing`, and `skills` fail explicitly as +unsupported in this release rather than being ignored. HarnessApp does not expose +workspace files by default, and enables an A2A data plane only when the caller +explicitly supplies A2A configuration. + +## Invocation boundary + +The public HarnessApp data plane supports `/v1/responses`, +`/v1/chat/completions`, and session routes. It is not an `agentengine web` +project and does not automatically produce a cloud deployment artifact. Use +`agentengine init` for a standard framework project, or +[Codex Managed Runtime](./managed-runtime) for YAML-driven managed Codex delivery. diff --git a/docs-site/content/docs/framework/guides/harness-app.mdx b/docs-site/content/docs/framework/guides/harness-app.mdx new file mode 100644 index 00000000..aa009e1a --- /dev/null +++ b/docs-site/content/docs/framework/guides/harness-app.mdx @@ -0,0 +1,66 @@ +--- +title: HarnessApp +description: 0.8 新增的最小声明式 composition root:用 YAML 组装统一 Runtime 数据面。 +--- + + +HarnessApp 是可运行的最小 composition root,不是完整 Agent 配置语言,也没有独立的 +`init`、托管 deploy 或插件生态承诺。生产凭据、真实 MCP 和部署互操作仍需按目标环境验收。 + + +HarnessApp 将一份严格校验的 YAML 映射到 `RuntimeAdapter` 和 FastAPI 数据面。它复用 +Responses、OpenAI-compatible API、会话和 health 路由,但不会复制全局应用或偷偷装入控制面。 + +## 最小应用 + +创建 `harness.yaml`: + +```yaml title="harness.yaml" +model: my-model +prompt: | + You are a concise coding assistant. +runtime: yaml +sandbox: + read_only: true +``` + +创建 ASGI 入口: + +```python title="app.py" +from ksadk.harness import HarnessApp + +app = HarnessApp.from_yaml("harness.yaml").build_app() +``` + +在本地以你的 OpenAI-compatible 模型环境变量运行: + +```bash title="shell" +export OPENAI_API_KEY= +export OPENAI_BASE_URL=https://api.example.com/v1 +uvicorn app:app --host 127.0.0.1 --port 8080 +``` + +`runtime: yaml` 使用 Harness 的 YAML runner;`runtime: codex` 使用本机 Codex runner, +因此还需要安装 `ksadk[codex]` 并配置与 [Codex Managed Runtime](./managed-runtime) +相同的本地 provider 环境。 + +## 支持的 YAML 范围 + +| 字段 | 行为 | +| --- | --- | +| `model` | 必填,默认模型;一次调用只能通过 `StartRequest` 覆盖。 | +| `prompt` | 必填,作为开发者指令。 | +| `runtime` | `yaml`(默认)或 `codex`。 | +| `mcp_tools` | 可选 MCP 工具列表;每项支持 `name`、`url`、`api_key`、`tool_filter`、`tool_name_prefix`。不要把真实 `api_key` 提交进 YAML。 | +| `sandbox.read_only` | 默认且当前只能为 `true`;写入策略会明确拒绝。 | + +`memory`、`knowledge`、`workflow`、`tracing` 和 `skills` 在本期会明确报“暂不支持”, +不会被静默忽略。HarnessApp 默认不提供 workspace files;只有调用方显式装配 A2A 配置时才 +开启 A2A 数据面。 + +## 调用边界 + +HarnessApp 的公开数据面兼容 `/v1/responses`、`/v1/chat/completions` 和 session 路由。 +它不等同于 `agentengine web` 项目,也不自动产生云端部署包;需要标准框架项目时继续使用 +`agentengine init`,需要 YAML 驱动的 Codex 托管交付时使用 +[Codex Managed Runtime](./managed-runtime)。 diff --git a/docs-site/content/docs/framework/guides/hosted-ui-events.en.mdx b/docs-site/content/docs/framework/guides/hosted-ui-events.en.mdx new file mode 100644 index 00000000..2406d289 --- /dev/null +++ b/docs-site/content/docs/framework/guides/hosted-ui-events.en.mdx @@ -0,0 +1,70 @@ +--- +title: "Hosted UI and Event Replay" +status: "new" +--- + +The `0.8.0` candidate separates Hosted UI transport, interactive presentation, +and runtime audit. OpenAI Responses remains the compatibility baseline, AG-UI +is an optional transport, A2UI represents structured activities, and +`RuntimeEvent` is the sole persistence and replay boundary. UI evolution does +not change the original model-call protocol. + + +This page describes interfaces and boundaries on the review branch. It does not mean that an npm/PyPI release exists. A real hosted deployment must still validate its gateway, authentication, runner, database, and provider credentials. + + +## Protocol Selection + +| Layer | Role | Behavior when unavailable | +| --- | --- | --- | +| OpenAI Responses | established `/v1/responses` and `/v1/chat/completions` request/response semantics | always available as the compatibility baseline | +| AG-UI | optional streaming transport between Hosted UI and runtime | fall back to Responses when the capability is not negotiated | +| A2UI | structured activities/components and user actions | does not replace model, tool, or approval policy | +| RuntimeEvent v1 | canonical session events, state, audit, and replay record | unknown event types cannot bypass conformance validation | + +AG-UI/A2UI is not a second model API. A client selects it only after Hosted +bootstrap advertises the capability; existing Responses clients can remain unchanged. + +## Interaction State + +The state of approvals, forms, and other actionable activities must be +projected from persisted RuntimeEvents, rather than only browser memory. When a +user submits an action, the runtime must validate actor, pending state, tool +receipt, and policy again; the button itself is not authorization. + +After a reload, the UI should reload session history and project a pending +interaction. A completed approval must not execute again. If it behaves +otherwise, replay the event order and terminal state first, then inspect the +runner's native interrupt/resume capability. + +## Read-Only Replay + +The two command entry points are equivalent: + +```bash +ksadk replay +agentengine replay --after-seq-id 120 --before-seq-id 260 --format json +``` + +The command reads the new event model from `RuntimeEventStore`. Its shared +parser projects text, reasoning, tools, artifacts, and run status; known but +unclassified events (including A2UI/A2A extensions) remain ordered extras. It +does not: + +- call a model provider; +- run a tool, MCP, sandbox, or approval action again; +- guess-convert legacy `assistant_message` SessionEvent records to RuntimeEvent. + +`--after-seq-id` is exclusive and `--before-seq-id` is an exclusive upper +bound. Read the full session first, then reduce to the cursors around the +failure to distinguish a missing event, an unprojected event, and an action +that was rejected or consumed. + +## Migration and Triage Order + +1. Keep the existing Responses call and first verify that session and RuntimeEvent persistence work. +2. Read Hosted bootstrap capabilities; only enable AG-UI/A2UI after negotiation succeeds. +3. For reloads, disconnects, or duplicate approvals, use `ksadk replay` to locate the event cursor and terminal state. +4. For LangGraph/ADK interrupt or resume, then validate the framework-native checkpoint configuration. New integrations must not depend on legacy LangChain continuity. + +For browser debugging, see [Local Web UI](local-web-ui). For session and checkpoint semantics, see [Sessions, Runtime, and Files](../../references/runtime-sessions-files). diff --git a/docs-site/content/docs/framework/guides/hosted-ui-events.mdx b/docs-site/content/docs/framework/guides/hosted-ui-events.mdx new file mode 100644 index 00000000..49a5e93a --- /dev/null +++ b/docs-site/content/docs/framework/guides/hosted-ui-events.mdx @@ -0,0 +1,62 @@ +--- +title: "Hosted UI 与事件回放" +status: "new" +--- + +`0.8.0` 候选将 Hosted UI 的 transport、交互展示和运行时审计分开:OpenAI Responses +保持兼容基线,AG-UI 是可选 transport,A2UI 是结构化 activity 表示,`RuntimeEvent` +则是唯一持久化和回放边界。这样前端能力演进不会改变模型调用的原始协议。 + + +本文说明的是评审分支中的接口与边界,不代表已发布的 npm/PyPI 版本。真实 hosted 环境仍应验证自身的网关、鉴权、runner、数据库和 provider 凭证。 + + +## 协议选择 + +| 层 | 角色 | 不可用时的行为 | +| --- | --- | --- | +| OpenAI Responses | 既有 `/v1/responses`、`/v1/chat/completions` 请求/响应语义 | 始终可作为兼容基线 | +| AG-UI | Hosted UI 与 runtime 的可选流式 transport | capability 未协商时回退到 Responses | +| A2UI | activity/组件和用户 action 的结构化表示 | 不替换模型、工具或审批 policy | +| RuntimeEvent v1 | session 内事件、状态、审计和回放的 canonical 记录 | 不接受未知事件类型绕过校验 | + +AG-UI/A2UI 不是第二套模型 API。客户端只有在 Hosted bootstrap 表明支持时才选择它;已有 +Responses 客户端可以维持原样。 + +## 交互状态 + +审批、表单和其他可操作 activity 的状态必须从已持久化的 RuntimeEvent 投影,而不是仅依赖 +浏览器内存。用户提交 action 时,runtime 需要再次校验 actor、pending 状态、tool receipt +和 policy;UI 按钮本身不是授权。 + +刷新后的 UI 应重新载入 session history 并投影 pending interaction。已完成的 approval +不应再次执行;若有异常,先用 replay 确认事件顺序和最终状态,再检查 runner 的 native +interrupt/resume 能力。 + +## 只读 replay + +两个命令入口等价: + +```bash +ksadk replay +agentengine replay --after-seq-id 120 --before-seq-id 260 --format json +``` + +它读取 `RuntimeEventStore` 中的新事件模型,统一 parser 会投影 text、reasoning、tool、artifact +和 run status;无法归类的已知事件(包括 A2UI/A2A 扩展)保留为有序 extras。`replay` 不会: + +- 调用模型 provider。 +- 重跑 tool、MCP、sandbox 或 approval action。 +- 把旧式 `assistant_message` 等 SessionEvent 猜测性转换成 RuntimeEvent。 + +`--after-seq-id` 为开区间,`--before-seq-id` 为不含上界。先从完整 session 读取,再缩小到 +异常前后的 cursor,可以区分“事件未写入”“事件写入但 UI 未投影”和“action 已被拒绝/消费”。 + +## 迁移与排障顺序 + +1. 保持现有 Responses 调用,先确认 session 和 RuntimeEvent 正常持久化。 +2. 读取 Hosted bootstrap capability,只有协商成功才启用 AG-UI/A2UI。 +3. 针对刷新、断线或重复审批,使用 `ksadk replay` 定位 event cursor 与 terminal 状态。 +4. 对 LangGraph/ADK 的 interrupt 或 resume,再验证框架原生 checkpoint 配置;新接入不要依赖旧 LangChain 连续性路径。 + +更多本地浏览器调试信息见[本地 Web UI](local-web-ui),会话与 checkpoint 语义见[会话、运行时与文件](../../references/runtime-sessions-files)。 diff --git a/docs-site/content/docs/framework/guides/managed-runtime.en.mdx b/docs-site/content/docs/framework/guides/managed-runtime.en.mdx new file mode 100644 index 00000000..f51dbe70 --- /dev/null +++ b/docs-site/content/docs/framework/guides/managed-runtime.en.mdx @@ -0,0 +1,114 @@ +--- +title: Codex Managed Runtime +description: Use one declarative manifest for native local debugging and a platform-managed cloud Runtime image. +--- + +Codex is KsADK's first `ManagedRuntime`. It separates the project declaration, the +native Codex process on a developer machine, and the Linux Runtime image selected +by AgentEngine in the cloud. Local development does not require Docker, and a +deployment bundle never contains Python dependencies, credentials, or a platform +binary. + + +This is the first declarative managed runtime in 0.8: a Codex agent is described +by YAML `model` and `prompt` fields rather than an object exported from `agent.py`. + + +## Create a project + +```bash title="shell" +pip install "ksadk[codex]" +ksadk init --framework codex my-codex-agent +cd my-codex-agent +ksadk web . --no-open +``` + +The template creates only `agentengine.yaml`, `.env`, `requirements.txt`, and a +README. It does not create `agent.py` or a second `codex.yaml`. The requirement +file is for native local development only and is never included in the managed +Runtime bundle. + +```yaml title="agentengine.yaml" +name: my-codex-agent +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime + +runtime: + name: codex + version: "0.144.4" + +model: gpt-5.1-codex +prompt: | + You are a coding assistant. +``` + +Set `runtime.version` explicitly whenever possible. When it is absent, local +`web` first asks the server bootstrap for its default; offline development uses +the installed version and reports that it is unlocked. A build never guesses an +unlocked version while offline. + +## Debug natively + +`openai-codex` resolves a Codex CLI binary for the current operating system. +macOS, Windows, and Linux therefore start a local subprocess rather than a +Docker image: + +```bash title="shell" +ksadk web . --port 8080 --no-open +``` + +Put model credentials in the uncommitted local `.env`. Do not include that file +in source control or a deployment bundle. + +## Build and deploy + +`artifact_type: ManagedRuntime` makes `ksadk build .` select managed mode. The +resulting zip is a reproducible local audit artifact, not a deployment prerequisite: + +```bash title="shell" +ksadk build . +``` + +The output is `--runtime.zip` and contains only a normalized +`agentengine.yaml` and `runtime-lock.json`. The lock records the manifest +protocol, Runtime name, resolved version, and manifest SHA-256. + +Deployment sends the normalized manifest, Runtime name, resolved version, and +manifest SHA-256 directly to the server. It does not upload a KS3 code package +or require Docker: + +```bash title="shell" +ksadk deploy . --target serverless +``` + +Do not use `--push`, `--ks3-bucket`, or `--ks3-path` for a ManagedRuntime. They +belong to the Code artifact path; the CLI rejects them instead of uploading the +manifest as a code package. + + +Forcing `ksadk build --mode code` for a Codex ManagedRuntime fails. This prevents +a macOS or Windows Codex binary from being packaged for a Linux deployment. +`--mode container` remains available for an advanced, user-maintained image. + + +The cloud Runtime catalog selects the default version and resolves it to an +immutable Linux image digest. If the catalog is disabled, deployment fails with +an actionable error instead of falling back to CodeBuilder. + +## Model protocol + +The official OpenAI upstream is used directly. For a custom OpenAI-compatible +upstream, KsADK enables its local Responses-to-Chat conversion proxy only when a +probe shows that it is needed; `KSADK_CODEX_USE_PROXY=1` or `0` overrides that +decision. The proxy exists only in the local debugging process and is never put +into the manifest or managed image. See the +[environment variables reference](/en/docs/references/environment-variables). + +## Credential and image boundary + +- The bundle excludes `.env`, `requirements.txt`, Python source, and native binaries. +- Platform-managed images are built for Linux `amd64` and `arm64`; there are no + macOS or Windows cloud images. +- The deployment result reports the actual Runtime name, version, and image digest. + Startup validates the manifest hash and expected Runtime to prevent silent drift. diff --git a/docs-site/content/docs/framework/guides/managed-runtime.mdx b/docs-site/content/docs/framework/guides/managed-runtime.mdx new file mode 100644 index 00000000..42e8d333 --- /dev/null +++ b/docs-site/content/docs/framework/guides/managed-runtime.mdx @@ -0,0 +1,107 @@ +--- +title: Codex Managed Runtime +description: 用同一份声明式 manifest 在本机原生调试,并由 AgentEngine 在云端选择托管 Runtime 镜像。 +--- + +Codex 是 KsADK 的第一个 `ManagedRuntime`。它把项目的声明、开发机上的原生 Codex +进程和云端 Linux Runtime 镜像分开:开发机不需要 Docker,部署包也不会带入 Python +依赖、模型凭据或平台二进制。 + + +这是 0.8 首个声明式托管 Runtime:Codex Agent 的行为由 YAML 中的 `model` 与 `prompt` +声明,而不是由 `agent.py` 导出对象。 + + +## 创建项目 + +```bash title="shell" +pip install "ksadk[codex]" +ksadk init --framework codex my-codex-agent +cd my-codex-agent +ksadk web . --no-open +``` + +模板只生成 `agentengine.yaml`、`.env`、`requirements.txt` 和 `README.md`;不会生成 +`agent.py` 或第二份 `codex.yaml`。`requirements.txt` 仅服务于本机调试,不会进入托管 +Runtime bundle。 + +```yaml title="agentengine.yaml" +name: my-codex-agent +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime + +runtime: + name: codex + version: "0.144.4" + +model: gpt-5.1-codex +prompt: | + 你是一个编码助手。 +``` + +`runtime.version` 建议始终显式指定。未指定时,本机 `web` 会尝试读取服务端 bootstrap +默认值;离线开发会使用已安装版本并明确标记为未锁定。`build` 不能在离线且未锁定版本时 +猜测 Runtime 版本。 + +## 本机调试 + +`openai-codex` 会为当前操作系统解析相应的 Codex CLI 二进制。因此 macOS、Windows +和 Linux 都是直接启动本地子进程,而不是启动 Docker 镜像: + +```bash title="shell" +ksadk web . --port 8080 --no-open +``` + +首次运行前在本机 `.env` 中配置模型凭据。不要把 `.env` 提交到 Git,也不要把它放进 +部署 bundle。 + +## 构建与部署 + +`artifact_type: ManagedRuntime` 会让 `ksadk build .` 自动进入 managed 模式。构建的 +zip 是本地可复现审计制品,不是部署前置条件: + +```bash title="shell" +ksadk build . +``` + +构建结果是 `--runtime.zip`,且只含两个文件:规范化的 +`agentengine.yaml` 与 `runtime-lock.json`。lock 固化 manifest protocol、Runtime 名称、 +解析后的版本和 manifest SHA-256。可以先检查其内容: + +```bash title="shell" +unzip -l .agentengine/managed_runtime/*-runtime.zip +``` + +实际部署直接发送规范化 manifest、Runtime 名称、解析版本和 manifest SHA-256;不上传 +KS3 代码包,也不要求 Docker: + +```bash title="shell" +ksadk deploy . --target serverless +``` + +不要为 ManagedRuntime 使用 `--push`、`--ks3-bucket` 或 `--ks3-path`。这些参数属于 +Code 制品路径;CLI 会明确拒绝它们,而不是把 manifest 当作代码包上传。 + + +对 Codex ManagedRuntime 强制 `ksadk build --mode code` 会失败。这样可以避免把开发机的 +macOS 或 Windows Codex 二进制误打进 Linux 部署包。`--mode container` 仍保留给你自己维护 +镜像的高级场景。 + + +云端 Runtime catalog 决定默认版本并解析为不可变的 Linux 镜像 digest。catalog 未启用时 +ManagedRuntime 部署会给出可操作错误,不会回退到 CodeBuilder。 + +## 模型协议 + +OpenAI 官方上游会直连。对于自定义 OpenAI-compatible 上游,KsADK 只在探测到需要时启用 +本机 Responses-to-Chat 转换代理;可以用 `KSADK_CODEX_USE_PROXY=1` 或 `0` 显式覆盖。 +该代理只存在于本机调试进程,不会写入 manifest 或托管镜像。详见 +[环境变量参考](/cn/docs/references/environment-variables)。 + +## 凭据和镜像边界 + +- Bundle 不包含 `.env`、`requirements.txt`、Python 源码或 native binary。 +- 托管镜像由平台维护,并在 Linux `amd64`、`arm64` 上构建;不会发布 macOS 或 Windows 镜像。 +- Cloud deployment 返回实际 Runtime 名称、版本和镜像 digest;启动时会校验 manifest hash 与 + 期望 Runtime,避免静默漂移。 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 b2e02db3..6d2f09f1 100644 --- a/docs-site/content/docs/framework/guides/memory-knowledge.en.mdx +++ b/docs-site/content/docs/framework/guides/memory-knowledge.en.mdx @@ -229,7 +229,7 @@ config: The manifest and secrets above are injected by the hosted platform. Public docs do not expose internal endpoints, accounts, or tokens; examples use placeholders such as `example.com` / `sk-test` / `cm-appkey-placeholder`. -For the full variable list, see [ksadk environment variables reference](../reference/environment-variables). +For the full variable list, see [ksadk environment variables reference](../../references/environment-variables). ## Failure Behavior diff --git a/docs-site/content/docs/framework/guides/memory-knowledge.mdx b/docs-site/content/docs/framework/guides/memory-knowledge.mdx index 6220b35d..7f683a06 100644 --- a/docs-site/content/docs/framework/guides/memory-knowledge.mdx +++ b/docs-site/content/docs/framework/guides/memory-knowledge.mdx @@ -88,7 +88,7 @@ def load_user_memory(query: str) -> str: - `KSADK_KB_DATASET_ID` - `KSADK_KB_TOP_K` -完整列表见 [环境变量](../reference/environment-variables)。 +完整列表见 [环境变量](../../references/environment-variables)。 ## OpenClaw memory backend(可选) @@ -126,4 +126,4 @@ config: 上述 manifest 与 secret 均由托管平台注入;公开文档不暴露内部 endpoint、account 或 token,示例值使用占位符(如 `example.com` / `sk-test` / `cm-appkey-placeholder`)。 -完整变量列表见 [ksadk 环境变量参考](../reference/environment-variables)。 +完整变量列表见 [ksadk 环境变量参考](../../references/environment-variables)。 diff --git a/docs-site/content/docs/framework/guides/meta.json b/docs-site/content/docs/framework/guides/meta.json index f4389623..e1fca7f9 100644 --- a/docs-site/content/docs/framework/guides/meta.json +++ b/docs-site/content/docs/framework/guides/meta.json @@ -1,18 +1,22 @@ { - "title": "核心", + "title": "运行与能力", "pages": [ - "runtime-architecture", + "frameworks", + "harness-app", + "local-web-ui", + "hosted-ui-events", "agent-context", "attachments-multimodal", - "local-web-ui", "workspace-files", "tools-and-skill-runtime", "memory-knowledge", "observability-tracing", - "runtime-products", + "a2a-runtime", + "managed-runtime", "build-and-package", - "frameworks", - "agent-best-practices", + "cloud-deployment", + "runtime-products", + "runtime-architecture", "web-ui-source" ] } diff --git a/docs-site/content/docs/framework/guides/runtime-architecture.en.mdx b/docs-site/content/docs/framework/guides/runtime-architecture.en.mdx index f2551158..253fcc61 100644 --- a/docs-site/content/docs/framework/guides/runtime-architecture.en.mdx +++ b/docs-site/content/docs/framework/guides/runtime-architecture.en.mdx @@ -10,7 +10,7 @@ protocols for terminal, browser, and API clients. ![KsADK request lifecycle](/assets/ksadk-request-lifecycle.svg) -Entry → Boot → HTTP/UI → Conversations Runtime → Runner → Session persistence → 0.6.7 Run capability layer. The important boundary is that the HTTP server and conversation runtime do not need to know whether the user project is ADK, LangGraph, LangChain, or DeepAgents. They call `BaseRunner.invoke()` or `BaseRunner.stream()`. +Entry → Boot → HTTP/UI → Conversations Runtime → Runner / RuntimeAdapter → Session and RuntimeEvent persistence. The important boundary is that the HTTP server and conversation runtime do not need to know whether the user project is ADK, LangGraph, LangChain, DeepAgents, or Codex. Code projects use `BaseRunner`; cross-Runtime control uses `RuntimeAdapter`. ## Core Packages @@ -19,6 +19,8 @@ Entry → Boot → HTTP/UI → Conversations Runtime → Runner → Session pers | `ksadk.cli` | command entry points, local process setup, user-facing errors | | `ksadk.detection` | project config, entry file, framework, and agent variable detection | | `ksadk.runners` | common runner contract and framework adapters | +| `ksadk.runtime` | 0.8 RuntimeAdapter, RuntimeEvent, and cancel / resume / checkpoint boundary | +| `ksadk.harness` | minimal YAML composition root that reuses the Runtime data plane | | `ksadk.server` | FastAPI app, local Web UI APIs, OpenAI-compatible endpoints | | `ksadk.conversations` | message normalization, session turn orchestration, protocol payloads | | `ksadk.sessions` | local and pluggable session storage | @@ -32,9 +34,9 @@ Entry → Boot → HTTP/UI → Conversations Runtime → Runner → Session pers 1. resolve the project directory. 2. re-execute inside the project virtual environment when needed. 3. load environment and project settings. - 4. detect the framework and entry point. - 5. create the matching runner. - 6. load the user agent. + 4. detect the framework and entry point, or read a declarative Codex manifest. + 5. create the matching Runner / RuntimeAdapter. + 6. load the user Agent, or validate the native managed Runtime. 7. run terminal mode or start the local HTTP server. @@ -130,6 +132,34 @@ The narrow contract also defines where custom behavior should live: If you are adding a new framework adapter, start with `BaseRunner` and implement `load_agent()`, `invoke()`, and `stream()` first. Add session continuity, dynamic model overrides, or protocol-specific event mapping only after the basic invoke and stream paths are stable. +## RuntimeAdapter (0.8) + +`RuntimeAdapter` is the platform runtime interface shared by runners, Harness, +Codex, and A2A. Ordinary projects do not implement it directly; use it when +integrating a new Runtime or handing a Runtime to the platform control plane. It +normalizes native differences into these fixed semantics: + +| Verb | Meaning | +| --- | --- | +| `start(request)` | starts a run with user, session, optional model, and configuration and returns an opaque `RunHandle`. | +| `stream(handle)` | yields normalized `RuntimeEvent` values asynchronously; it is not a duplex channel. | +| `cancel(handle)` | returns an explicit state (interrupted, pending recorded, not running, or failed), not an ambiguous boolean. | +| `resume(handle, target, payload)` | represents a resume target separately from approval, tool, or human response payload. | +| `checkpoint(handle)` | returns the actually declared checkpoint granularity and durability; it does not assume every Runtime can roll back. | +| `close(handle)` | releases resources held by the run. | + +`stream` carries events only; approval decisions, tool results, and HITL input use +`resume`. This gives AG-UI/A2UI, A2A, and session replay one event boundary and +prevents a framework's private SSE semantics from leaking to callers. +`attach(handle)` is the optional cross-process restoration seam and defaults to +fail-closed: persisting a handle does not prove that a native Runtime is restored +in the current process. + +For a new Runtime, first cover the normal `BaseRunner` path with +`RunnerRuntimeAdapter`, then declare cancel, resume, and checkpoint capabilities +one by one. Do not hide framework differences behind a fabricated “fully +supported” capability. + ## Request Lifecycle A normal non-streaming `/v1/responses` request follows this path: @@ -221,7 +251,7 @@ semantics (unified from 0.6.6, disable injection completed in 0.6.7): output items from the stream, so no `thinking` chunk is emitted over SSE. For the policy JSON structure, catalog fields, and injection details see -[Environment Variables](../reference/environment-variables). +[Environment Variables](../../references/environment-variables). ## Session And Context Boundary @@ -275,7 +305,7 @@ shown. For the full action paths, ResumeMode semantics, `SubscribeRunEvents` replay, and the 5-minute server protection timeout see -[Sessions And Files](../reference/runtime-sessions-files). +[Sessions And Files](../../references/runtime-sessions-files). ## Local Protocol Entrypoints diff --git a/docs-site/content/docs/framework/guides/runtime-architecture.mdx b/docs-site/content/docs/framework/guides/runtime-architecture.mdx index eda0dc1e..97f67d46 100644 --- a/docs-site/content/docs/framework/guides/runtime-architecture.mdx +++ b/docs-site/content/docs/framework/guides/runtime-architecture.mdx @@ -9,7 +9,7 @@ KsADK 本地运行时的核心职责是:加载用户 Agent 项目,把所选 ![KsADK 请求生命周期](/assets/ksadk-request-lifecycle.svg) -入口 → 启动 → HTTP/UI → 对话运行时 → Runner → Session 持久化 → 0.6.7 Run 能力层。关键边界是:HTTP server 和对话运行时不需要知道用户项目是 ADK、LangGraph、LangChain 还是 DeepAgents,它们调用 `BaseRunner.invoke()` 或 `BaseRunner.stream()`。 +入口 → 启动 → HTTP/UI → 对话运行时 → Runner / RuntimeAdapter → Session 与 RuntimeEvent 持久化。关键边界是:HTTP server 和对话运行时不需要知道用户项目是 ADK、LangGraph、LangChain、DeepAgents 还是 Codex;代码项目经 `BaseRunner`,跨 Runtime 控制面经 `RuntimeAdapter`。 ## 核心包 @@ -18,6 +18,8 @@ KsADK 本地运行时的核心职责是:加载用户 Agent 项目,把所选 | `ksadk.cli` | 命令入口、本地进程准备、用户可读错误 | | `ksadk.detection` | 项目配置、入口文件、框架和 Agent 变量检测 | | `ksadk.runners` | 统一 Runner 契约和框架适配器 | +| `ksadk.runtime` | 0.8 RuntimeAdapter、RuntimeEvent、取消 / 恢复 / checkpoint 边界 | +| `ksadk.harness` | 最小 YAML composition root,复用 Runtime 数据面 | | `ksadk.server` | FastAPI app、本地 Web UI API、OpenAI 兼容 endpoint | | `ksadk.conversations` | 消息规范化、session turn 编排、协议 payload | | `ksadk.sessions` | 本地和可插拔 session 存储 | @@ -31,9 +33,9 @@ KsADK 本地运行时的核心职责是:加载用户 Agent 项目,把所选 1. 解析项目目录。 2. 必要时重新进入项目虚拟环境执行。 3. 加载环境变量和项目设置。 - 4. 检测框架和入口点。 - 5. 创建匹配的 Runner。 - 6. 加载用户 Agent。 + 4. 检测框架和入口点,或读取 Codex 声明式 manifest。 + 5. 创建匹配的 Runner / RuntimeAdapter。 + 6. 加载用户 Agent,或验证本机托管 Runtime。 7. 进入终端模式或启动本地 HTTP server。 @@ -119,6 +121,29 @@ agent_variable: root_agent 新增框架 adapter 时,先从 `BaseRunner` 开始实现 `load_agent()`、`invoke()` 和 `stream()`。session continuity、动态模型覆盖或协议事件映射应在基础路径稳定后再加。 +## RuntimeAdapter(0.8) + +`RuntimeAdapter` 是跨 Runner、Harness、Codex 和 A2A 的平台运行接口。普通项目不需要 +直接实现它;只有接入一个新的 Runtime 或把 Runtime 交给平台控制面时才应使用。它把原生 +差异收敛为以下固定语义: + +| 动词 | 语义 | +| --- | --- | +| `start(request)` | 以用户、session、可选模型和配置启动 run,返回不透明 `RunHandle`。 | +| `stream(handle)` | 输出规范化 `RuntimeEvent` 异步事件流;它不是双工通道。 | +| `cancel(handle)` | 返回明确的取消状态(已中断、记录 pending、未运行或失败),而非模糊布尔值。 | +| `resume(handle, target, payload)` | 将恢复目标与审批/工具/人工回包分开表达。 | +| `checkpoint(handle)` | 返回真实声明的 checkpoint 粒度与持久化能力,不假设所有 Runtime 都能回滚。 | +| `close(handle)` | 释放 run 占用的运行期资源。 | + +`stream` 只传事件;审批决定、工具结果和 HITL 输入走 `resume`。这使 AG-UI/A2UI、A2A 和 +会话回放共享同一事件边界,也避免把某个框架的私有 SSE 语义泄露给调用方。`attach(handle)` +是可选的跨进程恢复接口,默认 fail-closed;不能因为持久化了一个 handle 就假设原生 +Runtime 已在当前进程恢复。 + +新 Runtime 应先用 `RunnerRuntimeAdapter` 覆盖标准 `BaseRunner` 路径,再逐项声明 cancel、 +resume 和 checkpoint 能力;不要用伪造的“全支持” capability 掩盖框架差异。 + ## 请求生命周期 普通非流式 `/v1/responses` 请求路径: @@ -200,7 +225,7 @@ thinking disable 注入语义共同决定(0.6.6 起统一,0.6.7 补全 disab 中过滤 reasoning 类 output item,因此 `thinking` chunk 不会出现在 SSE 流中。 具体的策略 JSON 结构、catalog 字段和注入细节见 -[环境变量参考](../reference/environment-variables)。 +[环境变量参考](../../references/environment-variables)。 ## Session 与上下文边界 @@ -245,7 +270,7 @@ resume 能力受 `GetAgentUiBootstrap` 返回的 `RuntimeCapabilities.ResumeRun. 同时满足 `RunLifecycle.Checkpoints` 与 `RunLifecycle.CheckpointResume` 才能展示。 完整的 action 路径、ResumeMode 语义、`SubscribeRunEvents` 续订与 5 分钟服务端保护 -超时见 [会话与文件](../reference/runtime-sessions-files)。 +超时见 [会话与文件](../../references/runtime-sessions-files)。 ## 本地协议入口 diff --git a/docs-site/content/docs/framework/guides/tools-and-skill-runtime.en.mdx b/docs-site/content/docs/framework/guides/tools-and-skill-runtime.en.mdx index d97af3c2..1ee609d9 100644 --- a/docs-site/content/docs/framework/guides/tools-and-skill-runtime.en.mdx +++ b/docs-site/content/docs/framework/guides/tools-and-skill-runtime.en.mdx @@ -1485,7 +1485,7 @@ inside a sandbox or an async branch. See [Agent Context](agent-context) for the full context fields and -accessors, and [OpenAI Compatible API](../reference/openai-compatible-api) +accessors, and [OpenAI Compatible API](../../references/openai-compatible-api) for the HTTP endpoint fields. ## Relationship To Other Guides @@ -1496,5 +1496,5 @@ Read this page with: - [Agent Context](agent-context) for the structured invocation context. - [Attachments And Multimodal Input](attachments-multimodal) for file input normalization. -- [Runtime Sessions And Files](../reference/runtime-sessions-files) for how +- [Runtime Sessions And Files](../../references/runtime-sessions-files) for how tool events and file references are stored in local sessions. diff --git a/docs-site/content/docs/framework/guides/tools-and-skill-runtime.mdx b/docs-site/content/docs/framework/guides/tools-and-skill-runtime.mdx index 905ee18e..499892a7 100644 --- a/docs-site/content/docs/framework/guides/tools-and-skill-runtime.mdx +++ b/docs-site/content/docs/framework/guides/tools-and-skill-runtime.mdx @@ -1451,7 +1451,7 @@ memory、sandbox、workspace 和 skill 在执行工具或读写状态时,会 更多上下文字段和读取方式见 [智能体上下文](agent-context),HTTP 入口字段 -见 [OpenAI 兼容 API](../reference/openai-compatible-api)。 +见 [OpenAI 兼容 API](../../references/openai-compatible-api)。 ## 相关指南 @@ -1460,4 +1460,4 @@ memory、sandbox、workspace 和 skill 在执行工具或读写状态时,会 - [框架接入](frameworks):runner 加载和多框架适配。 - [智能体上下文](agent-context):结构化调用上下文。 - [附件与多模态输入](attachments-multimodal):文件输入归一化。 -- [会话与文件](../reference/runtime-sessions-files):工具事件和文件引用如何进入本地会话。 +- [会话与文件](../../references/runtime-sessions-files):工具事件和文件引用如何进入本地会话。 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 c26c10c1..7abcf742 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 @@ -35,11 +35,11 @@ repository. `@kingsoftcloud/ksadk-web` npm package into `ksadk/server/static`: ```bash -# Default: pull npm latest +# Default: pull the verified npm version make sync-ksadk-web-static -# Pin a concrete version for a release candidate (0.6.7 maps to 0.2.15) -make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15 +# Pin the concrete 0.8.0 release-candidate version +make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0 ``` @@ -69,7 +69,7 @@ If the static assets are already synced and you want to skip sync, use | Variable | Default | Description | | --- | --- | --- | -| `KSADK_WEB_VERSION` | `latest` | npm package version; pin to a concrete version for release candidates, e.g. `0.2.15` | +| `KSADK_WEB_VERSION` | `0.3.0` | 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,15 +84,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.6.7`). -- The `KSADK_WEB_VERSION` / npm package version (e.g. `0.6.7` maps to `@kingsoftcloud/ksadk-web@0.2.15`). -- The sync command (e.g. `make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15`). +- 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 wheel / sdist audit result (`make public-build-check` / `twine check dist/*`). - -- `ksadk-python`: `0.6.7` -- npm package: `@kingsoftcloud/ksadk-web@0.2.15` -- sync: `make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15` + +- `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` - 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 a36fe4ce..1ae097f6 100644 --- a/docs-site/content/docs/framework/guides/web-ui-source.mdx +++ b/docs-site/content/docs/framework/guides/web-ui-source.mdx @@ -33,11 +33,11 @@ KsADK Web UI 源码属于独立仓库 `kingsoftcloud/ksadk-web`,并以 npm 包 `dist-ksadk` 同步到 `ksadk/server/static`: ```bash -# 默认拉 npm latest +# 默认拉已验证的 npm 版本 make sync-ksadk-web-static -# 发布候选固定具体版本(0.6.7 对应 0.2.15) -make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15 +# 0.8.0 发布候选固定具体版本 +make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0 ``` @@ -64,7 +64,7 @@ make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15 | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `KSADK_WEB_VERSION` | `latest` | npm 包版本;发布候选固定为具体版本,如 `0.2.15` | +| `KSADK_WEB_VERSION` | `0.3.0` | 已发布的 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,15 +78,14 @@ sync 优先级:`KSADK_WEB_RELEASE_URL` 显式 tarball > `npm pack` > registry 每次 `ksadk-python` release note 应记录: -- `ksadk-python` 版本(如 `0.6.7`)。 -- `KSADK_WEB_VERSION` / npm 包版本(如 `0.6.7` 对应 `@kingsoftcloud/ksadk-web@0.2.15`)。 -- sync 命令(如 `make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15`)。 +- `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`)。 - wheel / sdist 审计结果(`make public-build-check` / `twine check dist/*`)。 - -- `ksadk-python`: `0.6.7` -- npm 包: `@kingsoftcloud/ksadk-web@0.2.15` -- sync: `make sync-ksadk-web-static KSADK_WEB_VERSION=0.2.15` + +- `ksadk-python`: `0.8.0` +- npm 包: `@kingsoftcloud/ksadk-web@0.3.0` +- sync: `make sync-ksadk-web-static KSADK_WEB_VERSION=0.3.0` - 审计: `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 1cb97a38..65819d78 100644 --- a/docs-site/content/docs/framework/index.en.mdx +++ b/docs-site/content/docs/framework/index.en.mdx @@ -1,18 +1,44 @@ --- title: KsADK +description: The user path from a first local Agent to a managed cloud Runtime. --- -**Kingsoft Cloud Agent Development Kit** is a cloud-native framework to build, deploy, debug, and observe enterprise AI agents. Keep building business agents with Google ADK, LangGraph, LangChain, or DeepAgents, then use KsADK for a unified local runtime, browser debugging, OpenAI-compatible API, sandbox execution, OpenClaw / Hermes runtime launch paths, and observability. +**Kingsoft Cloud Agent Development Kit** builds, debugs, deploys, and observes +enterprise AI Agents. Start by choosing the shape of your project; every path +then shares the local Web UI, OpenAI-compatible API, sessions, and observability. ```bash pip install -U "ksadk[all]" ``` +## Choose a starting point + + + + + + + +## Complete the local-development loop + + + + + + + +## New in 0.8 and advanced topics + + + + + + + + +## Find the exact contract + - - - - - - + + diff --git a/docs-site/content/docs/framework/index.mdx b/docs-site/content/docs/framework/index.mdx index a4b53225..fedf9e1e 100644 --- a/docs-site/content/docs/framework/index.mdx +++ b/docs-site/content/docs/framework/index.mdx @@ -1,18 +1,44 @@ --- title: KsADK +description: 从第一个本地 Agent 到云端托管 Runtime 的用户文档入口。 --- -**Kingsoft Cloud Agent Development Kit** 是金山云智能体开发套件,用于构建、部署、调试、观测企业级 AI 智能体。继续使用 Google ADK、LangGraph、LangChain 或 DeepAgents 编写业务 Agent,再用 KsADK 获得统一的本地运行、浏览器调试、OpenAI-Compatible API、沙箱执行、OpenClaw / Hermes 运行时和可观测体验。 +**Kingsoft Cloud Agent Development Kit** 用于构建、调试、部署和观测企业级 AI +Agent。先选择你的项目形态;所有路径随后都能复用同一套本地 Web UI、OpenAI +兼容 API、会话和可观测能力。 ```bash 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 497487c4..a5515320 100644 --- a/docs-site/content/docs/framework/meta.json +++ b/docs-site/content/docs/framework/meta.json @@ -3,31 +3,37 @@ "icon": "LayoutGrid", "root": true, "pages": [ - "---[Rocket]入门---", + "---[Rocket]从这里开始---", "getting-started/index", - "getting-started/why-ksadk", - "getting-started/architecture", - "getting-started/comparison", - "getting-started/concepts", "getting-started/quickstart", "getting-started/configuration", "getting-started/project-structure", - "---[Boxes]核心---", - "guides/runtime-architecture", + "getting-started/concepts", + "getting-started/why-ksadk", + "getting-started/architecture", + "getting-started/comparison", + "---[Code2]创建与本地调试---", + "guides/frameworks", + "tutorials", + "guides/harness-app", + "guides/local-web-ui", + "guides/hosted-ui-events", + "---[Blocks]运行时能力---", "guides/agent-context", "guides/attachments-multimodal", - "guides/local-web-ui", "guides/workspace-files", "guides/tools-and-skill-runtime", "guides/memory-knowledge", "guides/observability-tracing", - "guides/runtime-products", + "---[Network]互操作---", + "guides/a2a-runtime", + "---[Cloud]构建与部署---", + "guides/managed-runtime", "guides/build-and-package", - "guides/frameworks", - "guides/web-ui-source", - "---[Cloud]云端部署---", "guides/cloud-deployment", - "---[BookOpen]教程---", - "tutorials" + "guides/runtime-products", + "---[Wrench]高级与维护---", + "guides/runtime-architecture", + "guides/web-ui-source" ] } diff --git a/docs-site/content/docs/framework/quickstart.mdx b/docs-site/content/docs/framework/quickstart.mdx deleted file mode 100644 index 04cadd04..00000000 --- a/docs-site/content/docs/framework/quickstart.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: 快速开始 ---- - -快速开始占位页 — 内容迁移阶段会补全。 - -## 创建并运行智能体 - -```bash -agentengine init demo-agent -f langgraph -cd demo-agent -agentengine run -i -``` diff --git a/docs-site/content/docs/framework/tutorials/adk-agent.en.mdx b/docs-site/content/docs/framework/tutorials/adk-agent.en.mdx index f806ed18..8a56eb3d 100644 --- a/docs-site/content/docs/framework/tutorials/adk-agent.en.mdx +++ b/docs-site/content/docs/framework/tutorials/adk-agent.en.mdx @@ -182,7 +182,7 @@ build/ ## Next Steps -- [Frameworks](../../guides/frameworks.en): ADK runner behavior and detection order. -- [Agent Best Practices](../../best-practices/agent-best-practices.en): ADK, memory, knowledge, MCP, and Skill Runtime patterns. -- [Environment Variables](../../reference/environment-variables.en): model, memory, knowledge, MCP, and tracing variables. -- [OpenAI-Compatible API](../../reference/openai-compatible-api.en): local protocol shape. +- [Frameworks](../guides/frameworks): ADK runner behavior and detection order. +- [Agent Best Practices](best-practices/agent-best-practices): ADK, memory, knowledge, MCP, and Skill Runtime patterns. +- [Environment Variables](/en/docs/references/environment-variables): model, memory, knowledge, MCP, and tracing variables. +- [OpenAI-Compatible API](/en/docs/references/openai-compatible-api): local protocol shape. diff --git a/docs-site/content/docs/framework/tutorials/adk-agent.mdx b/docs-site/content/docs/framework/tutorials/adk-agent.mdx index 01bdf907..18e3c9b1 100644 --- a/docs-site/content/docs/framework/tutorials/adk-agent.mdx +++ b/docs-site/content/docs/framework/tutorials/adk-agent.mdx @@ -177,7 +177,7 @@ build/ ## 后续阅读 -- [框架接入](../../guides/frameworks):ADK runner、加载边界和检测顺序。 -- [Agent 最佳实践](../../best-practices/agent-best-practices):ADK、记忆、知识库、MCP 和 Skill Runtime 模式。 -- [环境变量](../../reference/environment-variables):模型、记忆、知识库、MCP 和 tracing 变量。 -- [OpenAI 兼容 API](../../reference/openai-compatible-api):本地协议形态。 +- [框架接入](../guides/frameworks):ADK runner、加载边界和检测顺序。 +- [Agent 最佳实践](best-practices/agent-best-practices):ADK、记忆、知识库、MCP 和 Skill Runtime 模式。 +- [环境变量](/cn/docs/references/environment-variables):模型、记忆、知识库、MCP 和 tracing 变量。 +- [OpenAI 兼容 API](/cn/docs/references/openai-compatible-api):本地协议形态。 diff --git a/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.en.mdx b/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.en.mdx index 8072011c..f7158bc2 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.en.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.en.mdx @@ -466,13 +466,13 @@ def write_report(path: str, content: str) -> dict: These examples apply the patterns above to real scenarios: - + A real demo combining a LangGraph outer graph with AgentEngine Toolsets. - + A real demo of an agent that resumes long-running tasks. - + A tutorial for hosting a custom-UI agent on AgentEngine. diff --git a/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.mdx b/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.mdx index e09358be..b43da3d0 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/agent-best-practices.mdx @@ -433,13 +433,13 @@ def write_report(path: str, content: str) -> dict: 下面几个例子把上面的模式落到真实场景: - + LangGraph 外层图 + AgentEngine Toolsets 的真实 demo。 - + 长任务断点恢复 Agent 的真实 demo。 - + 自定义 UI Agent 托管到 AgentEngine 的教程。 diff --git a/docs-site/content/docs/framework/tutorials/best-practices/codex-yaml-agent.en.mdx b/docs-site/content/docs/framework/tutorials/best-practices/codex-yaml-agent.en.mdx new file mode 100644 index 00000000..2b5e055f --- /dev/null +++ b/docs-site/content/docs/framework/tutorials/best-practices/codex-yaml-agent.en.mdx @@ -0,0 +1,115 @@ +--- +title: "YAML Is the Agent: Codex" +description: "New in 0.8: declare, debug, and deploy a Codex Managed Runtime from one agentengine.yaml." +--- + + +Codex is KsADK's first “YAML is the agent” pattern: `agentengine.yaml` is the +single configuration source. There is no `agent.py`, no second `codex.yaml`, and +no developer-machine binary enters the cloud deployment artifact. + + +Use this pattern when Codex is the coding agent and the same declaration should +work for native local debugging and managed cloud delivery. Use LangGraph, ADK, +or HarnessApp when you need custom Python orchestration, business functions, or a +complex state graph rather than putting code into this manifest. + +## Create the minimal project + +```bash title="shell" +python -m venv .venv +source .venv/bin/activate +pip install -U "ksadk[codex]" +agentengine init --framework codex my-codex-agent +cd my-codex-agent +``` + +The template has only four user files: `agentengine.yaml`, `.env`, +`requirements.txt`, and a README. `requirements.txt` is for native local +debugging only; it never enters a ManagedRuntime artifact. + +## Make YAML the agent + +Version the identity, Runtime, model, and developer instruction together: + +```yaml title="agentengine.yaml" +name: review-helper +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime + +runtime: + name: codex + version: "0.144.4" + +model: kimi-k3 +prompt: | + You are the team's code-review assistant. + State findings and their impact before giving minimal, executable fixes. + Do not guess about unread files; say which command you would run to verify. +``` + +`runtime.version` is the reproducibility boundary. Local `web` validates the +installed `openai-codex` package and CLI binary; the cloud catalog resolves the +same version to an immutable Linux image digest. Development may omit it to use a +server default; offline local development can use the installed version, but a +production build should always lock it explicitly. + +## Debug natively, without Docker + +Put model credentials only in an uncommitted `.env`: + +```bash title=".env" +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.example.com/v1 +OPENAI_MODEL_NAME=gpt-5.1-codex +``` + +Start the browser debugging UI: + +```bash title="shell" +agentengine web . --port 8080 --no-open +``` + +`openai-codex` resolves the matching CLI for macOS, Windows, or Linux and starts +a native subprocess, not a Docker image. The official OpenAI upstream is direct. +For a custom chat-only upstream, KsADK conservatively probes whether it needs a +local Responses-to-Chat proxy. Set `KSADK_CODEX_USE_PROXY=1` to force the proxy or +`0` to force direct mode. + +## Deploy: the manifest does not go through KS3 + +Build the reproducible local audit zip (only normalized YAML and a lock): + +```bash title="shell" +agentengine build . +unzip -l .agentengine/managed_runtime/review-helper-1.0.0-runtime.zip +``` + +Deployment does not upload that zip. The CLI sends an inline manifest, Runtime +name, version, and SHA-256 to the server: + +```bash title="shell" +agentengine deploy . --target serverless --dry-run +agentengine deploy . --target serverless +``` + +Do not use `--push`, `--ks3-bucket`, `--ks3-path`, or `--mode code`; they belong +to the Code artifact path and ManagedRuntime rejects them. Choose explicit +`--mode container` only when your team maintains the Linux image itself. + +## Pre-commit checklist + +- Keep only declarations in `agentengine.yaml`; never include API keys, cloud + AK/SK values, or private endpoints. +- Put `.env` and `.agentengine/` in `.gitignore`; commit only a placeholder + `.env.example`. +- Lock `runtime.version` and run `agentengine web . --no-open` once on every + development platform. +- Run `agentengine build .` and verify the zip contains only `agentengine.yaml` + and `runtime-lock.json`. +- Before deployment, run `agentengine deploy . --target serverless --dry-run` + and verify the returned Runtime version and image digest. + +See [Codex Managed Runtime](../../guides/managed-runtime) for the full runtime +contract, image boundary, and environment variables. diff --git a/docs-site/content/docs/framework/tutorials/best-practices/codex-yaml-agent.mdx b/docs-site/content/docs/framework/tutorials/best-practices/codex-yaml-agent.mdx new file mode 100644 index 00000000..471a7ab3 --- /dev/null +++ b/docs-site/content/docs/framework/tutorials/best-practices/codex-yaml-agent.mdx @@ -0,0 +1,103 @@ +--- +title: "YAML 即 Agent:Codex" +description: "0.8 新增:用单一 agentengine.yaml 声明、调试并部署 Codex Managed Runtime。" +--- + + +Codex 是 KsADK 的第一个 YAML 即 Agent 范式:`agentengine.yaml` 是唯一配置源, +没有 `agent.py`、没有第二份 `codex.yaml`,也不会把开发机二进制打进云端部署包。 + + +这份实践适合“用 Codex 作为编码 Agent,并希望本机原生调试与云端托管交付保持同一份 +声明”的场景。需要自定义 Python 编排、业务函数或复杂 state graph 时,应使用 LangGraph、 +ADK 或 HarnessApp,而不是把代码塞进这个 manifest。 + +## 创建最小项目 + +```bash title="shell" +python -m venv .venv +source .venv/bin/activate +pip install -U "ksadk[codex]" +agentengine init --framework codex my-codex-agent +cd my-codex-agent +``` + +模板只有四个用户文件:`agentengine.yaml`、`.env`、`requirements.txt` 和 README。 +其中 `requirements.txt` 只服务本机原生调试;它绝不会进入 ManagedRuntime 制品。 + +## 让 YAML 成为 Agent + +把 Agent 的身份、版本、Runtime、模型和开发者指令一起版本化: + +```yaml title="agentengine.yaml" +name: review-helper +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime + +runtime: + name: codex + version: "0.144.4" + +model: kimi-k3 +prompt: | + 你是团队的代码审查助手。 + 先说明发现的问题和影响,再给出可执行、最小范围的修复建议。 + 不要臆测未读取的文件;需要验证时说明将执行的命令。 +``` + +`runtime.version` 是可复现边界:本机 `web` 会验证已安装的 `openai-codex` 与 CLI 二进制, +云端 catalog 将同一版本解析为不可变 Linux 镜像 digest。开发时可省略它以使用服务端默认值; +离线时本机可使用已安装版本,但生产构建应始终显式锁定。 + +## 本机调试:不使用 Docker + +把模型凭据仅放入未提交的 `.env`: + +```bash title=".env" +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.example.com/v1 +OPENAI_MODEL_NAME=gpt-5.1-codex +``` + +然后启动浏览器调试 UI: + +```bash title="shell" +agentengine web . --port 8080 --no-open +``` + +`openai-codex` 会为 macOS、Windows 或 Linux 解析对应 CLI;它启动的是本机子进程, +不是 Docker 镜像。对 OpenAI 官方上游走直连;对自定义 chat-only 上游,KsADK 会保守探测 +是否需要本地 Responses-to-Chat 代理。需要固定行为时设 `KSADK_CODEX_USE_PROXY=1` 强制代理, +或设为 `0` 强制直连。 + +## 部署:manifest 不走 KS3 + +构建可复现的本地审计 zip(只含规范化 YAML 与 lock): + +```bash title="shell" +agentengine build . +unzip -l .agentengine/managed_runtime/review-helper-1.0.0-runtime.zip +``` + +部署不上传这份 zip。CLI 直接把内联 manifest、Runtime 名称、版本和 SHA-256 发送给服务端: + +```bash title="shell" +agentengine deploy . --target serverless --dry-run +agentengine deploy . --target serverless +``` + +因此不要使用 `--push`、`--ks3-bucket`、`--ks3-path` 或 `--mode code`。它们属于 Code +制品路径,ManagedRuntime 会拒绝;若团队自己维护 Linux 镜像,才选择显式 `--mode container`。 + +## 提交前检查 + +- `agentengine.yaml` 只保留声明,不放 API key、AK/SK 或私有 endpoint。 +- `.env` 与 `.agentengine/` 必须在 `.gitignore`;只提交 `.env.example` 占位模板。 +- 固定 `runtime.version`,并在各开发平台执行一次 `agentengine web . --no-open`。 +- 用 `agentengine build .` 检查 zip 仅有 `agentengine.yaml` 与 `runtime-lock.json`。 +- 部署前先执行 `agentengine deploy . --target serverless --dry-run`,确认服务端返回的 + Runtime 版本和镜像 digest。 + +完整的 runtime contract、镜像边界和环境变量见 +[Codex Managed Runtime](../../guides/managed-runtime)。 diff --git a/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.en.mdx b/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.en.mdx index 03969532..f1a1052e 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.en.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.en.mdx @@ -385,7 +385,7 @@ Expected output (serverless): How the serverless image build packages `research-ui/dist` into the artifact, and UI config env injection details. - + How a custom research workbench consumes `CheckpointResumeCapability` to render a resume panel. diff --git a/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.mdx b/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.mdx index baa92190..5fba4b39 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/custom-ui-agent.mdx @@ -385,7 +385,7 @@ curl -s -X POST https:///agentengine/api/v1/GetAgentUiBootstrap \ serverless 镜像构建如何把 `research-ui/dist` 打进制品,以及 UI 配置环境变量注入细节。 - + 自定义研究工作台如何消费 `CheckpointResumeCapability` 渲染恢复面板。 diff --git a/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.en.mdx b/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.en.mdx index 0fe03ea6..1b85f1b1 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.en.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.en.mdx @@ -239,7 +239,7 @@ The `component_status` and `graph_status` tools are not just for end users. When Reference for framework adapters and runner loading boundaries. - + Runtime semantics for sessions, uploads, and workspace files. diff --git a/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.mdx b/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.mdx index 7217c65e..8437cb73 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/langgraph-toolsets.mdx @@ -239,7 +239,7 @@ agent_variable: root_agent 框架适配与 runner 加载边界的参考。 - + session、上传和 workspace 文件的运行时语义。 diff --git a/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.en.mdx b/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.en.mdx index 84544994..474d43c9 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.en.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.en.mdx @@ -478,13 +478,13 @@ Not by default. Locally it uses `InMemorySaver` for interactive demos and does n ## Further reading - + An outer LangGraph graph routes; a ReAct subgraph talks to KSADK built-in toolsets — Skill Space, Workspace, Sandbox. Build a locally runnable LangGraph agent from scratch — a good starting point for basic integration. - + Sessions, uploads, workspace files, and checkpoint persistence backends. diff --git a/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.mdx b/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.mdx index ba97e986..4ac7dd1f 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.mdx +++ b/docs-site/content/docs/framework/tutorials/best-practices/long-task-resume.mdx @@ -478,13 +478,13 @@ REPORT_STAGES = ( ## 后续阅读 - + 外层 LangGraph 图做路由,ReAct 子图通过 KSADK 内置 toolsets 与 Skill Space、Workspace、Sandbox 交互。 从零创建一个可本地运行的 LangGraph 智能体,适合先跑通基础接入。 - + session、上传、工作区文件和 checkpoint 持久化后端。 diff --git a/docs-site/content/docs/framework/tutorials/best-practices/meta.json b/docs-site/content/docs/framework/tutorials/best-practices/meta.json index 22673742..9328c497 100644 --- a/docs-site/content/docs/framework/tutorials/best-practices/meta.json +++ b/docs-site/content/docs/framework/tutorials/best-practices/meta.json @@ -1,6 +1,7 @@ { "title": "最佳实践", "pages": [ + "codex-yaml-agent", "langgraph-toolsets", "long-task-resume", "custom-ui-agent", diff --git a/docs-site/content/docs/framework/tutorials/langgraph-agent.en.mdx b/docs-site/content/docs/framework/tutorials/langgraph-agent.en.mdx index 685d03b8..47e3e963 100644 --- a/docs-site/content/docs/framework/tutorials/langgraph-agent.en.mdx +++ b/docs-site/content/docs/framework/tutorials/langgraph-agent.en.mdx @@ -211,6 +211,6 @@ build/ ## Next Steps -- Add framework-specific patterns from [Frameworks](../../guides/frameworks.en). -- Add session or file handling from [Runtime Sessions And Files](../../reference/runtime-sessions-files.en). -- Package the project with [Build And Package](../../guides/build-and-package.en). +- Add framework-specific patterns from [Frameworks](../guides/frameworks). +- Add session or file handling from [Runtime Sessions And Files](/en/docs/references/runtime-sessions-files). +- Package the project with [Build And Package](../guides/build-and-package). diff --git a/docs-site/content/docs/framework/tutorials/langgraph-agent.mdx b/docs-site/content/docs/framework/tutorials/langgraph-agent.mdx index c49108c0..28c233c2 100644 --- a/docs-site/content/docs/framework/tutorials/langgraph-agent.mdx +++ b/docs-site/content/docs/framework/tutorials/langgraph-agent.mdx @@ -205,6 +205,6 @@ build/ ## 后续阅读 -- 阅读 [框架接入](../../guides/frameworks),了解框架适配和 runner 加载边界。 -- 阅读 [会话与文件](../../reference/runtime-sessions-files),了解 session、上传和工作区文件。 -- 阅读 [构建与打包](../../guides/build-and-package),了解本地构建和公开 artifact 规则。 +- 阅读 [框架接入](../guides/frameworks),了解框架适配和 runner 加载边界。 +- 阅读 [会话与文件](/cn/docs/references/runtime-sessions-files),了解 session、上传和工作区文件。 +- 阅读 [构建与打包](../guides/build-and-package),了解本地构建和公开 artifact 规则。 diff --git a/docs-site/content/docs/framework/tutorials/meta.json b/docs-site/content/docs/framework/tutorials/meta.json index 49342fa2..71a466c9 100644 --- a/docs-site/content/docs/framework/tutorials/meta.json +++ b/docs-site/content/docs/framework/tutorials/meta.json @@ -1,5 +1,5 @@ { - "title": "教程", + "title": "按框架学习", "pages": [ "langgraph-agent", "adk-agent", diff --git a/docs-site/content/docs/references/contributing/release.en.mdx b/docs-site/content/docs/references/contributing/release.en.mdx index ccef15a7..5176c443 100644 --- a/docs-site/content/docs/references/contributing/release.en.mdx +++ b/docs-site/content/docs/references/contributing/release.en.mdx @@ -88,7 +88,7 @@ packages are now published to PyPI via OIDC Trusted Publishing. The workflow runs the following steps: 1. **Sync frontend static assets**: `make sync-ksadk-web-static` pulls - `dist-ksadk` from `@kingsoftcloud/ksadk-web@latest` by default and unpacks + `dist-ksadk` from the pinned, published `@kingsoftcloud/ksadk-web` version and unpacks it into the packaging tree. When triggered via `workflow_dispatch`, the `ksadk_web_version` input pins a specific npm version (for example `1.2.3`). 2. **Release preflight**: `make public-preflight` chains `public-audit`, diff --git a/docs-site/content/docs/references/contributing/release.mdx b/docs-site/content/docs/references/contributing/release.mdx index 378561ce..c93d7efd 100644 --- a/docs-site/content/docs/references/contributing/release.mdx +++ b/docs-site/content/docs/references/contributing/release.mdx @@ -43,7 +43,7 @@ OIDC Trusted Publishing 上传到 PyPI。 workflow 执行步骤如下: 1. **同步前端静态资源**:`make sync-ksadk-web-static`,默认从 - `@kingsoftcloud/ksadk-web@latest` 拉取 `dist-ksadk` 解包到打包目录; + `@kingsoftcloud/ksadk-web` 的已发布固定版本拉取 `dist-ksadk` 解包到打包目录; 通过 `workflow_dispatch` 触发时可传 `ksadk_web_version` 输入指定版本 (如 `1.2.3`)。 2. **发布前预检**:`make public-preflight`,串起 `public-audit`、 diff --git a/docs-site/content/docs/references/environment-variables.en.mdx b/docs-site/content/docs/references/environment-variables.en.mdx index 558b8a14..0310535f 100644 --- a/docs-site/content/docs/references/environment-variables.en.mdx +++ b/docs-site/content/docs/references/environment-variables.en.mdx @@ -37,6 +37,56 @@ OPENAI_BASE_URL=https://api.example.com/v1 OPENAI_MODEL_NAME=my-model ``` +## Codex and model proxy + +| Variable | Purpose | +| --- | --- | +| `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) | +| `KSADK_PROXY_TOKEN` | bearer token between the loopback proxy and Codex subprocess (sensitive; normally generated by the SDK) | +| `KSADK_MODEL_PROXY_ENABLED` | enables the experimental model protocol proxy; default `0` | +| `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 | + +## Runtime, A2A, and A2UI in 0.8 + +The following variables belong to 0.8 managed Runtime and A2A data-plane +contracts. Variables marked “platform injected” are deployment inputs; users +must not put them in `.env` or commit them. + +### Codex ManagedRuntime + +| Variable | Owner | Purpose | +| --- | --- | --- | +| `AGENTENGINE_BASE_INSTRUCTIONS` | console / server | server input used to write manifest `prompt` during declarative Codex creation; local `web` reads YAML directly. | +| `AGENTENGINE_MANAGED_RUNTIME_NAME` | platform injected | Runtime name resolved by the catalog; image startup must match manifest `runtime.name`. | +| `AGENTENGINE_MANAGED_RUNTIME_VERSION` | platform injected | Runtime version resolved by the catalog; the image also validates installed `openai-codex`. | +| `AGENTENGINE_MANIFEST_PROTOCOL` | platform injected | inline manifest protocol; currently `runtime-manifest/v1`. | +| `AGENTENGINE_MANIFEST_SHA256` | platform injected | SHA-256 of normalized `agentengine.yaml`; checked at startup to prevent silent drift. | + +### A2A Runtime + +| Variable | Owner | Purpose | +| --- | --- | --- | +| `KSADK_A2A_SPACE_IDS` | platform injected | JSON array of A2A Spaces configured for the Runtime; callers must select `space_id` explicitly when there is more than one. | +| `KSADK_A2A_CONTROL_PLANE_URL` | platform injected | A2A control-plane base URL; it does not fall back to the generic AgentEngine server URL. | +| `KSADK_A2A_ENABLE_PUBLIC_EGRESS` | deployment layer | allows `external_public`; absence is fail-closed as `false`. | +| `KSADK_A2A_EVENT_OUTBOX_PATH` | platform / developer | durable A2A task-event outbox path; production should place it on a writable Runtime volume. | +| `KSADK_A2A_TOKEN_DIR` | token sidecar | projected directory for audience workload JWTs; never create or commit these tokens yourself. | + +### Hosted UI + +| Variable | Owner | Purpose | +| --- | --- | --- | +| `KSADK_A2UI_GENERATION_TIMEOUT_SECONDS` | platform / developer | A2UI structured-generation timeout in seconds; valid range `1` through `120`, default `20`. | + +See [A2A Runtime](../framework/guides/a2a-runtime), +[Codex Managed Runtime](../framework/guides/managed-runtime), and +[Hosted UI and Event Replay](../framework/guides/hosted-ui-events) for their +protocol and runtime boundaries. + ## Unified Model Policy And Fallback (0.6.6+) Hosted deployments can inject a shared policy through `AGENTENGINE_MODEL_POLICY_JSON`. Hermes, OpenClaw, and generic agents share one default semantics. In 0.6.6, model policy v1 defaults to `glm-5.2` as the primary model, `kimi-k2.7-code` as the multimodal model, and `deepseek-v4-pro` as the fallback model. Explicit request fields and explicit environment-variable overrides (such as `HERMES_DEFAULT_MODEL` / `OPENCLAW_FALLBACK_MODEL`) still take precedence over policy defaults. @@ -262,7 +312,7 @@ KSADK_MCP_SERVERS='[{"name":"docs","url":"http://127.0.0.1:9000/mcp"}]' | Variable | Purpose | | --- | --- | -| `KSADK_WEB_VERSION` | `@kingsoftcloud/ksadk-web` npm dist-tag or version used by `make sync-ksadk-web-static`; default `latest` | +| `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_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 | diff --git a/docs-site/content/docs/references/environment-variables.mdx b/docs-site/content/docs/references/environment-variables.mdx index e024105f..d6938626 100644 --- a/docs-site/content/docs/references/environment-variables.mdx +++ b/docs-site/content/docs/references/environment-variables.mdx @@ -37,6 +37,54 @@ OPENAI_BASE_URL=https://api.example.com/v1 OPENAI_MODEL_NAME=my-model ``` +## Codex 与模型代理 + +| 变量 | 用途 | +| --- | --- | +| `KSADK_CODEX_USE_PROXY` | `1` 强制启用 Codex 本地协议代理,`0` 强制直连;未设置时只对自定义上游做保守探测 | +| `KSADK_PROXY_UPSTREAM_BASE` | Codex proxy 上游 URL 覆盖,默认读取 OpenAI 兼容 base URL | +| `KSADK_PROXY_UPSTREAM_KEY` | Codex proxy 上游凭据覆盖(敏感) | +| `KSADK_PROXY_TOKEN` | 本地回环 proxy 与 Codex 子进程之间的 bearer token(敏感,通常由 SDK 生成) | +| `KSADK_MODEL_PROXY_ENABLED` | 启用实验性模型协议转换层,默认 `0` | +| `KSADK_MODEL_PROXY_AGENTS` | 逗号分隔的 agent allowlist | +| `KSADK_MODEL_PROXY_MODELS` | 逗号分隔的 model allowlist | +| `KSADK_MODEL_PROXY_DENY` | 逗号分隔的 denylist,用于紧急关闭代理 | + +## 0.8 Runtime、A2A 与 A2UI + +以下变量由 0.8 的托管 Runtime 和 A2A 数据面使用。标记为“平台注入”的变量是部署契约, +用户不应写进 `.env` 或提交到仓库。 + +### Codex ManagedRuntime + +| 变量 | 所有者 | 用途 | +| --- | --- | --- | +| `AGENTENGINE_BASE_INSTRUCTIONS` | 控制台 / Server | 声明式创建 Codex Agent 时写入 manifest `prompt` 的服务端输入;本机 `web` 直接读取 YAML。 | +| `AGENTENGINE_MANAGED_RUNTIME_NAME` | 平台注入 | catalog 解析后的 Runtime 名称;镜像启动时必须匹配 manifest `runtime.name`。 | +| `AGENTENGINE_MANAGED_RUNTIME_VERSION` | 平台注入 | catalog 解析后的 Runtime 版本;镜像同时校验已安装的 `openai-codex`。 | +| `AGENTENGINE_MANIFEST_PROTOCOL` | 平台注入 | 内联 manifest 协议;当前为 `runtime-manifest/v1`。 | +| `AGENTENGINE_MANIFEST_SHA256` | 平台注入 | 规范化 `agentengine.yaml` 的 SHA-256;启动时用于防止静默漂移。 | + +### A2A Runtime + +| 变量 | 所有者 | 用途 | +| --- | --- | --- | +| `KSADK_A2A_SPACE_IDS` | 平台注入 | Runtime 已配置 A2A Space 的 JSON 数组;一个以上 Space 时调用方必须显式选择 `space_id`。 | +| `KSADK_A2A_CONTROL_PLANE_URL` | 平台注入 | A2A control plane 基地址;不会回退到通用 AgentEngine server URL。 | +| `KSADK_A2A_ENABLE_PUBLIC_EGRESS` | 部署层注入 | 是否允许 `external_public`;未注入时按 `false` fail-closed。 | +| `KSADK_A2A_EVENT_OUTBOX_PATH` | 平台 / 开发者 | durable A2A task-event outbox 路径;生产环境应在 Runtime 可写持久卷中。 | +| `KSADK_A2A_TOKEN_DIR` | token sidecar | audience workload JWT 的投影目录;不要手动创建或提交这些 token。 | + +### Hosted UI + +| 变量 | 所有者 | 用途 | +| --- | --- | --- | +| `KSADK_A2UI_GENERATION_TIMEOUT_SECONDS` | 平台 / 开发者 | A2UI 结构化生成超时秒数;有效范围 `1` 到 `120`,默认 `20`。 | + +这些变量的协议与运行边界见 [A2A Runtime](../framework/guides/a2a-runtime)、 +[Codex Managed Runtime](../framework/guides/managed-runtime) 和 +[Hosted UI 与事件回放](../framework/guides/hosted-ui-events)。 + ## 统一模型策略与 fallback(0.6.6+) 托管部署中,平台可以通过 `AGENTENGINE_MODEL_POLICY_JSON` 注入统一模型策略,Hermes、OpenClaw 和通用 Agent 共用同一套默认语义。0.6.6 默认策略 v1 使用 `glm-5.2` 作为主模型、`kimi-k2.7-code` 作为多模态模型、`deepseek-v4-pro` 作为 fallback;显式请求参数或显式环境变量(如 `HERMES_DEFAULT_MODEL` / `OPENCLAW_FALLBACK_MODEL`)仍优先于策略默认值。 @@ -261,7 +309,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 dist-tag 或版本,默认 `latest` | +| `KSADK_WEB_VERSION` | `make sync-ksadk-web-static` 使用的已发布 `@kingsoftcloud/ksadk-web` npm 版本,0.8.0 默认 `0.3.0`;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 下载 | diff --git a/docs-site/content/docs/references/index.en.mdx b/docs-site/content/docs/references/index.en.mdx index c089c700..460fe759 100644 --- a/docs-site/content/docs/references/index.en.mdx +++ b/docs-site/content/docs/references/index.en.mdx @@ -5,7 +5,7 @@ title: Reference Reference material for KsADK: CLI, environment variables, OpenAI-compatible API, project config, remote runtime API, runtime sessions and files, security boundaries, troubleshooting, and contributing. - + diff --git a/docs-site/content/docs/references/index.mdx b/docs-site/content/docs/references/index.mdx index 89bb6870..ef2c6b8d 100644 --- a/docs-site/content/docs/references/index.mdx +++ b/docs-site/content/docs/references/index.mdx @@ -5,7 +5,7 @@ title: 参考 KsADK 的参考资料:CLI、环境变量、OpenAI-Compatible API、项目配置、远程运行时 API、运行时会话与文件、安全边界、故障排查与贡献指南。 - + diff --git a/docs-site/content/docs/references/project-config.en.mdx b/docs-site/content/docs/references/project-config.en.mdx index e0eea2d7..4291ff38 100644 --- a/docs-site/content/docs/references/project-config.en.mdx +++ b/docs-site/content/docs/references/project-config.en.mdx @@ -20,12 +20,14 @@ agent_variable: root_agent | Field | Required | Meaning | | --- | --- | --- | | `name` | recommended | display name and default agent name | -| `framework` | recommended | adapter family: `adk`, `langgraph`, `langchain`, `deepagents` | -| `entry_point` | recommended | Python file loaded by the local runtime | -| `agent_variable` | optional | exported object name, default `root_agent` | +| `framework` | recommended | code adapter family: `adk`, `langgraph`, `langchain`, `deepagents`; declarative Runtime: `codex` | +| `entry_point` | recommended for code projects | Python file loaded by the local runtime; Codex does not need one | +| `agent_variable` | optional for code projects | exported object name, default `root_agent`; Codex does not need one | | `package` | optional | package directory when different from project name | | `region` | optional | cloud region for deployment-shaped commands | -| `artifact_type` | optional | packaging mode for specialized runtimes | +| `artifact_type` | optional | packaging mode for specialized runtimes; Codex ManagedRuntime requires `ManagedRuntime` | +| `runtime` | required for ManagedRuntime | Runtime name and locked version, for example `name: codex`, `version: "0.144.4"` | +| `model` / `prompt` | required for declarative Runtime | model and developer instruction | | `ui_profile` | optional | UI profile selector, e.g. `custom`; defaults to `auto` (new in 0.6.7) | | `ui_bundle_path` | optional | project-relative path to a custom UI static bundle, e.g. `research-ui/dist` (new in 0.6.7) | | `ui_path` | optional | custom UI mount path, e.g. `/` or `/research`; under the `custom` profile, `/chat` is auto-corrected to `/` (new in 0.6.7) | @@ -39,10 +41,27 @@ agent_variable: root_agent | `langgraph` | `root_agent = graph.compile()` | | `langchain` | runnable chain or agent | | `deepagents` | `root_agent = create_deep_agent(...)` | +| `codex` | declarative `model` and `prompt`; no exported Python object | -Public examples should avoid specialized hosted runtime values unless the guide -also explains public credentials, public runtime images, and local fallback -behavior. +## Codex ManagedRuntime (new in 0.8) + +```yaml title="agentengine.yaml" +name: my-codex-agent +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime +runtime: + name: codex + version: "0.144.4" +model: gpt-5.1-codex +prompt: | + You are a coding assistant. +``` + +A Codex manifest has no `entry_point`, `agent_variable`, or `agent.py`. Its +`model` and `prompt` define agent behavior. See +[YAML Is the Agent: Codex](../framework/tutorials/best-practices/codex-yaml-agent) +for native local debugging and direct deployment. ## Environment Variables @@ -86,7 +105,7 @@ complete prerequisites, commands, and verification steps. ## Detection Fallback -If no config file exists, KsADK tries to infer project shape from: +If no config file exists, KsADK tries to infer a code-project shape from: - `langgraph.json` - a package matching the project name. @@ -95,15 +114,16 @@ If no config file exists, KsADK tries to infer project shape from: - common agent variables in source. Inference is convenient for local experiments, but explicit config is better for -docs, samples, tests, and release candidates. +docs, samples, tests, and release candidates. Codex requires an explicit +manifest. ## Validation Checklist Before publishing a project: -- `entry_point` exists. -- `agent_variable` is exported by the entry point. +- for a code framework, `entry_point` exists and `agent_variable` is exported. +- for Codex ManagedRuntime, `runtime.name` and a reproducible `runtime.version` are declared; there is no entry point to validate. - `.env` is ignored by Git. - placeholder provider values are used in docs. - cloud fields are optional or documented with public prerequisites. -- `agentengine run . -i` works from a clean checkout. +- the matching local command works from a clean checkout: `agentengine run . -i` for a code project or `agentengine web . --no-open` for Codex. diff --git a/docs-site/content/docs/references/project-config.mdx b/docs-site/content/docs/references/project-config.mdx index 6aceb389..207a5dca 100644 --- a/docs-site/content/docs/references/project-config.mdx +++ b/docs-site/content/docs/references/project-config.mdx @@ -19,9 +19,12 @@ agent_variable: root_agent | --- | --- | | `name` | 项目名 | | `version` | 项目版本 | -| `framework` | `adk`、`langgraph`、`langchain` 或 `deepagents` | -| `entry_point` | 导入 Agent 的 Python 文件 | -| `agent_variable` | 文件中导出的 Agent 对象变量名 | +| `framework` | 代码框架为 `adk`、`langgraph`、`langchain` 或 `deepagents`;声明式 Runtime 使用 `codex` | +| `entry_point` | 代码框架导入 Agent 的 Python 文件;Codex 不需要它 | +| `agent_variable` | 代码框架文件中导出的 Agent 对象变量名;Codex 不需要它 | +| `artifact_type` | 可选 specialized packaging mode;Codex ManagedRuntime 必填 `ManagedRuntime` | +| `runtime` | ManagedRuntime 的名称与锁定版本,例如 `name: codex`、`version: "0.144.4"` | +| `model` / `prompt` | 声明式 Runtime 的模型与开发者指令 | | `ui_profile` | optional,UI profile 选择器,如 `custom`;默认 `auto`(0.6.7 新增) | | `ui_bundle_path` | optional,custom UI 静态 bundle 相对项目路径,如 `research-ui/dist`(0.6.7 新增) | | `ui_path` | optional,custom UI 挂载路径,如 `/` 或 `/research`;custom profile 下 `/chat` 会被自动修正为 `/`(0.6.7 新增) | @@ -45,6 +48,25 @@ entry_point: my_agent/agent.py agent_variable: root_agent ``` +Codex ManagedRuntime(0.8 新增): + +```yaml title="agentengine.yaml" +name: my-codex-agent +version: "1.0.0" +framework: codex +artifact_type: ManagedRuntime +runtime: + name: codex + version: "0.144.4" +model: gpt-5.1-codex +prompt: | + 你是一个编码助手。 +``` + +Codex manifest 没有 `entry_point`、`agent_variable` 或 `agent.py`。它由 +`model` 与 `prompt` 定义 agent 行为;完整的本机和部署流程见 +[YAML 即 Agent:Codex](../framework/tutorials/best-practices/codex-yaml-agent)。 + ## 环境变量 模型配置通常放在 `.env`: @@ -78,4 +100,5 @@ AK/SK、镜像仓库密码不要写入 YAML;它们应保存在未提交的 `.e ## 检测规则 运行时优先读取显式 YAML;没有配置时才尝试 `langgraph.json`、`agent.py`、 -`main.py`、`app.py` 等约定路径。公开项目建议始终使用 YAML。 +`main.py`、`app.py` 等代码项目约定路径。Codex 必须使用显式 manifest;公开项目建议 +始终使用 YAML。 diff --git a/docs-site/content/docs/references/security-boundaries.en.mdx b/docs-site/content/docs/references/security-boundaries.en.mdx index 9b25d1b1..693893b6 100644 --- a/docs-site/content/docs/references/security-boundaries.en.mdx +++ b/docs-site/content/docs/references/security-boundaries.en.mdx @@ -86,7 +86,7 @@ The Python wheel should not include: parameterized. Each embedded UI refresh should record which `ksadk-web` ref produced the static -assets. See [Web UI Repository](../guides/web-ui-source). +assets. See [Web UI Repository](/en/docs/framework/guides/web-ui-source). - The editable `ksadk/server/web-ui` source, `node_modules/`, and historical diff --git a/docs-site/content/docs/references/troubleshooting.en.mdx b/docs-site/content/docs/references/troubleshooting.en.mdx index 74364718..41fe9aac 100644 --- a/docs-site/content/docs/references/troubleshooting.en.mdx +++ b/docs-site/content/docs/references/troubleshooting.en.mdx @@ -4,6 +4,30 @@ title: Troubleshooting This page covers common local setup and runtime issues. +## `ksadk` Or `a2a` Fails After An Upgrade + +Upgrade KsADK in the same Python environment that runs the command. Use an +isolated environment per project: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade "ksadk[all]" +python -m pip show ksadk a2a-sdk +ksadk --help +``` + +`python -m pip` must refer to the same interpreter that will run `ksadk`. +Calling a different `pip` can install the new dependency into another +environment. An editable source installation also needs the install command +again after its dependency metadata changes. + +KsADK 0.8 pins `a2a-sdk==1.1.0` for its A2A implementation. Projects that +still pin A2A 0.3, including some VEADK releases, cannot share that virtual +environment; create separate environments instead. With an incompatible +optional SDK, `ksadk --help` remains available and `ksadk a2a` reports the +specific loading error. + ## `agentengine` Command Not Found Confirm the virtual environment is active and the package is installed: diff --git a/docs-site/content/docs/references/troubleshooting.mdx b/docs-site/content/docs/references/troubleshooting.mdx index d8370873..4f0a98f9 100644 --- a/docs-site/content/docs/references/troubleshooting.mdx +++ b/docs-site/content/docs/references/troubleshooting.mdx @@ -2,6 +2,26 @@ title: 故障排查 --- +## 升级后 `ksadk` 或 `a2a` 命令报依赖错误 + +在运行命令的同一个 Python 环境中升级 KsADK。推荐为每个项目创建独立环境: + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade "ksadk[all]" +python -m pip show ksadk a2a-sdk +ksadk --help +``` + +`python -m pip` 必须和随后运行 `ksadk` 的 Python 属于同一环境;直接执行另一个 +`pip` 可能把新依赖装到别的解释器。通过源码可编辑安装时,修改依赖声明后也必须再次执行 +安装命令,才能刷新已安装包的元数据。 + +KsADK 0.8 的 A2A 实现精确锁定 `a2a-sdk==1.1.0`。仍锁定 A2A 0.3 的项目(例如某些 +VEADK 版本)不能与它共用一个虚拟环境,请分别创建环境。依赖不兼容时,`ksadk --help` +仍可使用;执行 `ksadk a2a` 会显示具体的加载错误。 + ## 模型调用失败 检查: diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index 1e1e7dcc..6e1e6958 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -1,8 +1,11 @@ # KsADK Public Release Approval Record -This record must be filled after maintainer review and before any external -write action, including GitHub release tags, GitHub Releases, TestPyPI, or -PyPI publication. +This is the 0.8.0 release-candidate record. It must be completed after +maintainer review and before any external release write action, including +GitHub release tags, GitHub Releases, TestPyPI, or PyPI publication. + +Candidate preparation is not approval. Blank or pending sign-offs below must +remain blocking until the reviewed public candidate and staging evidence exist. ## Required Approval Decisions @@ -11,7 +14,7 @@ PyPI publication. | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.7.0 | +| Python package version | 0.8.0 | | Public docs URL | https://kingsoftcloud.github.io/ksadk-python/ | | Package metadata repository URL | https://github.com/kingsoftcloud/ksadk-python | | Package metadata documentation URL | https://kingsoftcloud.github.io/ksadk-python/ | @@ -23,15 +26,15 @@ Record exactly one approved source publication strategy. | Strategy | Approved | | --- | --- | -| Reviewed GitHub pull request | No | -| Clean export from reviewed candidate | Yes | +| Reviewed GitHub pull request | Pending | +| Clean export from reviewed candidate | Pending | | Rewritten Git history after secret scan | No | The approved strategy must name the reviewed commit, tag, pull request, or export archive used for: -- `ksadk-python`: reviewed internal commit `82646634a88675f0f10ec8c89d2e86e778088079`, including final runtime-name validation for generated projects. The earlier `a40948ea79aed139caf2db17af38bf0032f6a73e` candidate passed its full suite and clean-export preflight; a fresh clean export and complete `make public-preflight` are required for `8264663` before publication. -- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.19` from commit `76029d965ccc328a916bc81e8ba66b5e002a6e5b`; paired with Python reviewed commit `82646634a88675f0f10ec8c89d2e86e778088079`; published by the trusted GitHub npm workflow on 2026-07-15. +- `ksadk-python`: candidate branch `feat/0.8-runtime-foundation`; the reviewed commit, clean-export archive and public-candidate commit are pending review. +- `ksadk-web`: candidate branch `feat/0.8-agui-a2ui-web`, prepared as `@kingsoftcloud/ksadk-web@0.3.0`; trusted npm publication and the exact source commit are pending review. Both approved source references must include the current commit SHA at approval time. This prevents a stale approval record from passing after candidate @@ -39,18 +42,17 @@ changes. ## Required Evidence Before Approval -- `make public-preflight` exits successfully. -- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.7.0` confirms +- `make public-preflight` exits successfully after the reviewed `ksadk-web@0.3.0` + package is available in npm. +- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.8.0` confirms the target version is not already on PyPI. - Branch protection and publish environment are configured according to `.github/BRANCH_PROTECTION.md`. -- Staging E2E for the reviewed runtime images and control-plane candidate exits - successfully before GitHub Release, PyPI, or npm workflows are approved. -- Hosted workspace zip export, model policy defaults, fallback behavior, - Hermes/OpenClaw default images, long-task resume, and terminal reconnect are - covered by the staging E2E evidence. +- Staging E2E covers the reviewed runtime images and control-plane candidate, + including AG-UI/A2UI Hosted interaction and the Codex provider path when an + approved credential is available. - GitHub PR checks are green on the reviewed commit. -- Release notes and `CHANGELOG.md` were reviewed, including the complete 0.7.0 summary. +- Release notes and `CHANGELOG.md` are reviewed, including the complete 0.8.0 summary. - Public README and docs were reviewed for sensitive environment names, internal endpoints, tokens, customer data, and inaccurate competitor claims. - PyPI/TestPyPI credentials stay outside the repository. @@ -59,6 +61,6 @@ changes. | Role | Name | Decision | Date | | --- | --- | --- | --- | -| Maintainer | xiayu | Approved | 2026-07-15 | -| Security reviewer | xiayu | Approved | 2026-07-15 | -| Release owner | xiayu | Approved | 2026-07-15 | +| Maintainer | | Pending review | | +| Security reviewer | | Pending review | | +| Release owner | | Pending review | | 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 9423f61f..f93b811b 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" @@ -331,6 +331,12 @@ | `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_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_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_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`。 | | `AGENTENGINE_UI_DIR` | 本地 Web UI / Sessions | 否 | 未设置 | 无 | 否 | 本地开发者 | 否 | 本地 UI 静态目录覆盖,主要用于 Web/文件上传本地调试。 | @@ -338,12 +344,26 @@ | `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 | 否 | `latest` | 可显式设置 `0.2.7` / `v0.2.7` | 否 | 构建环境 / 开发者 | 否 | `make sync-ksadk-web-static` 使用的 `@kingsoftcloud/ksadk-web` npm dist-tag 或版本,默认消费最新 release。 | +| `KSADK_WEB_VERSION` | Hosted Web UI static sync | 否 | `0.3.0` | 可显式设置已发布版本 | 否 | 构建环境 / 发版负责人 | 否 | `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_USE_PROXY` | Codex runtime | 否 | 自动探测 | 无 | 否 | 开发者 / 平台 | 否 | `1` 强制启用本地 Responses-to-Chat proxy,`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;通常不应手动设置。 | +| `AGENTENGINE_BASE_INSTRUCTIONS` | Codex ManagedRuntime 声明式创建 | 否 | 内置中文编码助手指令 | 无 | 否 | 控制台 / Server | 否 | 服务端生成 `agentengine.yaml` 时写入 `prompt`。本地 `ksadk web` 直接读取 YAML,不需要设置此变量。 | +| `AGENTENGINE_MANAGED_RUNTIME_NAME` | Codex Runtime 镜像 | 是(平台注入) | 未设置 | 无 | 否 | AgentEngine Server | 否 | 服务端 catalog 解析后的 Runtime 名称;镜像启动时必须与挂载 YAML 的 `runtime.name` 一致。 | +| `AGENTENGINE_MANAGED_RUNTIME_VERSION` | Codex Runtime 镜像 | 是(平台注入) | 未设置 | 无 | 否 | AgentEngine Server | 否 | catalog 解析后的 Runtime 版本;镜像启动时同时校验已安装的 `openai-codex` 版本。 | +| `AGENTENGINE_MANIFEST_PROTOCOL` | Codex Runtime 镜像 | 是(平台注入) | `runtime-manifest/v1`(旧 bundle 兼容) | 无 | 否 | AgentEngine Server | 否 | 内联 manifest 协议版本。当前只支持 `runtime-manifest/v1`。 | +| `AGENTENGINE_MANIFEST_SHA256` | Codex Runtime 镜像 | 是(平台注入) | 未设置 | 无 | 否 | AgentEngine Server | 否 | 服务端规范化 `agentengine.yaml` 的 SHA-256;镜像启动时校验挂载内容。 | +| `KSADK_MODEL_PROXY_ENABLED` | Model proxy | 否 | `0` | 无 | 否 | 开发者 / 平台 | 否 | 启用实验性模型协议转换层。 | +| `KSADK_MODEL_PROXY_AGENTS` | Model proxy | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 逗号分隔的 agent allowlist。 | +| `KSADK_MODEL_PROXY_MODELS` | Model proxy | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 逗号分隔的 model allowlist。 | +| `KSADK_MODEL_PROXY_DENY` | Model proxy | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | 逗号分隔的 denylist;用于紧急关闭代理。 | | `KSADK_GLOBAL_CONFIG_ENV_KEYS` | CLI | 否 | 未设置 | 无 | 否 | CLI 内部 | 否 | CLI 启动时记录哪些环境变量由 `~/.agentengine/settings.json` 补入,用于区分用户显式环境变量和全局配置默认值。 | +| `KSADK_HOSTED_UI_GUIDELINES` | Hosted A2UI(内部常量) | 否 | 代码常量 | 无 | 否 | SDK 内部 | 否 | Hosted A2UI 的内置生成与设计指引;它不是受支持的环境变量,不应通过部署配置覆盖。 | | `KSYUN_IAM_URL` | 身份反查 | 否 | `https://iam.api.ksyun.com` | 无 | 否 | CLI | 否 | 覆盖 IAM endpoint,用于 AK/SK 反查子账号 user uuid。内部账号 AK 公网访问被拒时,CLI 自动 fallback 到 `http://iam.inner.api.ksyun.com`。 | | `AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC` | 本地 runtime CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 runtime 是否在虚拟环境中 re-exec。普通用户通常无需设置。 | | `AGENTENGINE_WEB_VENV_REEXEC` | 本地 Web CLI | 否 | 自动判断 | 无 | 否 | 本地开发者 / 测试 | 否 | 控制本地 Web 命令是否在虚拟环境中 re-exec。普通用户通常无需设置。 | diff --git a/export-manifest.json b/export-manifest.json index 0ed7c83c..d940e4e4 100644 --- a/export-manifest.json +++ b/export-manifest.json @@ -1,12 +1,15 @@ { - "generatedAt": "2026-07-15T15:04:58.326366+00:00", + "generatedAt": "2026-07-29T07:37:44.862331+00:00", "targetRepository": "https://github.com/kingsoftcloud/ksadk-python", "documentation": "https://kingsoftcloud.github.io/ksadk-python/", - "exportPathCount": 556, - "excludedPathCount": 207, + "exportPathCount": 662, + "excludedPathCount": 294, "excludedPaths": [ + "docs/A2UI-agent驱动UI技术方案.md", "docs/Agent 开发者上下文接入指南.md", "docs/DeepAgents说明.md", + "docs/a2ui-v1-alignment-proposal.md", + "docs/adk-multi-version-compat.md", "docs/adk-resume-integration-design.md", "docs/archive/kb-memory/knowledge_base_integration_plan.md", "docs/archive/kb-memory/knowledge_base_test_report.md", @@ -19,6 +22,7 @@ "docs/archive/workspace/workspace_files_v1_实施说明.md", "docs/archive/workspace/workspace_files_去重改造方案比较稿.md", "docs/frameworks/LangGraph开发最佳实践.md", + "docs/frameworks/人机交互跨框架接入指南.md", "docs/guides/Agent 开发者上下文接入指南.md", "docs/guides/DeepAgents说明.md", "docs/guides/LangGraph开发最佳实践.md", @@ -50,6 +54,8 @@ "docs/prompt-driven-agent-creation-draft.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", @@ -61,8 +67,11 @@ "docs/工作区文件技术设计.md", "docs/平台可观测与用户反馈设计方案.md", "docs/知识库与记忆示例.md", + "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", @@ -88,11 +97,84 @@ "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/a2a/compose.yaml", + "tests/a2a/pg_process_worker.py", + "tests/a2a/process_server.py", + "tests/a2a/test_a2a_discovery.py", + "tests/a2a/test_a2a_protocol_e2e.py", + "tests/a2a/test_card.py", + "tests/a2a/test_context_adapter.py", + "tests/a2a/test_context_store.py", + "tests/a2a/test_control_plane.py", + "tests/a2a/test_credential_and_outbound.py", + "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_bootstrap.py", + "tests/a2a/test_owner_resolver.py", + "tests/a2a/test_pg_process_recovery.py", + "tests/a2a/test_process_interop.py", + "tests/a2a/test_reasoning_streaming.py", + "tests/a2a/test_resume_store.py", + "tests/a2a/test_task_event_dispatcher.py", + "tests/a2a/test_trusted_identity.py", + "tests/a2ui/test_a2ui_core.py", + "tests/a2ui/test_a2ui_renderer.py", + "tests/agui/__init__.py", + "tests/agui/test_copilotkit_a2ui_compat.py", + "tests/agui/test_langgraph_interrupt_e2e.py", + "tests/agui/test_protocol_contract.py", + "tests/agui/test_runtime_adapter.py", + "tests/agui/test_runtime_preprocessing.py", + "tests/cli/test_cli_alignment.py", + "tests/cli/test_run_chain_e2e.py", + "tests/codex/fake_app_server.py", + "tests/codex/test_proxy_injection.py", + "tests/codex/test_sdk_transport.py", + "tests/e2e/test_codex_sdk_process_e2e.py", + "tests/events/fixtures/runtime_event_v1.json", + "tests/events/test_replay_parser.py", + "tests/events/test_runtime_event.py", + "tests/events/test_runtime_event_deserialization.py", + "tests/events/test_runtime_event_store.py", + "tests/harness/__init__.py", + "tests/harness/conftest.py", + "tests/harness/fixtures/__init__.py", + "tests/harness/fixtures/mcp_server.py", + "tests/harness/test_harness_app.py", + "tests/harness/test_harness_codex.py", + "tests/harness/test_harness_isolation.py", + "tests/harness/test_harness_mcp_e2e.py", + "tests/harness/test_harness_reasoner.py", + "tests/harness/test_harness_sandbox_policy.py", "tests/long_task/__init__.py", "tests/long_task/test_checkpoint_resume.py", "tests/long_task/test_runtime_cancel.py", "tests/long_task/test_tool_idempotency.py", "tests/mock_responses_server.py", + "tests/model_proxy/__init__.py", + "tests/model_proxy/test_bootstrap.py", + "tests/model_proxy/test_cache.py", + "tests/model_proxy/test_detect.py", + "tests/model_proxy/test_gate.py", + "tests/model_proxy/test_namespace.py", + "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_checkpoint_capability_honesty.py", + "tests/runners/test_codex_runtime.py", + "tests/runners/test_codex_sdk_surface.py", + "tests/runners/test_langchain_hitl_langgraph.py", + "tests/runners/test_runtime_resume_cancel_e2e.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_ui_bootstrap_agui.py", "tests/skills/__init__.py", "tests/skills/test_adk_runner_skill_runtime.py", "tests/skills/test_loader_and_tools.py", @@ -101,6 +183,7 @@ "tests/skills/test_runtime_agent.py", "tests/skills/test_service_client_http.py", "tests/skills/test_skill_service_client.py", + "tests/skills/test_skill_space_routing.py", "tests/skills/test_web_artifacts_fixture.py", "tests/snapshots/error_hint_snapshots.txt", "tests/snapshots/help_snapshots.txt", @@ -149,10 +232,13 @@ "tests/test_config_root_visibility.py", "tests/test_container_registry_credentials.py", "tests/test_conversation_runtime.py", + "tests/test_conversation_runtime_structure.py", "tests/test_deepagents_integration.py", "tests/test_deepagents_runner_skill_runtime.py", "tests/test_deploy_integration.py", + "tests/test_detector_accuracy.py", "tests/test_error_utils_hints.py", + "tests/test_events_for_agent.py", "tests/test_help_snapshots.py", "tests/test_hermes_container_builder.py", "tests/test_hermes_terminal.py", @@ -160,14 +246,15 @@ "tests/test_identity_resolver.py", "tests/test_json_contracts.py", "tests/test_ks3_uploader_urls.py", - "tests/test_langchain_runner_session_continuity.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", "tests/test_long_task_pilot_validation.py", + "tests/test_mcp_regressions.py", "tests/test_mcp_runtime.py", + "tests/test_message_projection.py", "tests/test_model_context.py", "tests/test_model_policy.py", "tests/test_openai_protocol_e2e.py", @@ -266,13 +353,19 @@ "scripts/open_source_audit.py", "scripts/prepare_ksadk_python_export.py", "scripts/prepare_ksadk_web_export.py", - "scripts/public_secret_audit.py" + "scripts/public_secret_audit.py", + "scripts/verify_ksadk_web_static.py" ], "tests": [ + "tests/cli/test_cmd_create_codex.py", "tests/conftest.py", + "tests/runners/test_codex_runner.py", "tests/test_check_approval_record.py", "tests/test_check_publication_state.py", "tests/test_config_env_registry.py", + "tests/test_managed_runtime_builder.py", + "tests/test_managed_runtime_native_smoke.py", + "tests/test_managed_runtime_resolution.py", "tests/test_markdown_repair.py", "tests/test_open_source_audit.py", "tests/test_public_release_positioning.py", diff --git a/ksadk/a2a/__init__.py b/ksadk/a2a/__init__.py index 5c9f12ce..df51f0fd 100644 --- a/ksadk/a2a/__init__.py +++ b/ksadk/a2a/__init__.py @@ -1,15 +1,182 @@ -"""A2A protocol helpers for KsADK runners and remote agents.""" +"""A2A 协议数据面 (goal-05 清洁重写,wire 1.0 / a2a-sdk 1.1.0)。 -from ksadk.a2a.card_builder import AgentCardBuilder -from ksadk.a2a.client import RemoteA2AAgent, RemoteA2AClient -from ksadk.a2a.executor import KsAgentExecutor -from ksadk.a2a.server import KsA2AServer, to_a2a +旧 0.3 demo(``A2AStarletteApplication`` + ``InMemoryTaskStore`` + 顶层 ``url``) +已整体推倒,不保留兼容。本包: + +- :mod:`ksadk.a2a.card` — wire 1.0 AgentCard(``supportedInterfaces``)。 +- :mod:`ksadk.a2a.task_store` — durable ``DatabaseTaskStore``(表 ``ksadk_a2a_tasks``)。 +- :mod:`ksadk.a2a.executor` — runner → A2A 请求生命周期桥接。 +- :mod:`ksadk.a2a.task_adapter` — A2A Task ↔ Runtime 映射(§7.2),cancel 走 RuntimeAdapter。 +- :mod:`ksadk.a2a.event_adapter` — A2A Message/Task/Artifact ↔ RuntimeEvent。 +- :mod:`ksadk.a2a.server` — ``A2AProtocolServer``(route factories + handler + store)。 +- :mod:`ksadk.a2a.routes` — ``add_a2a_protocol_routes`` 装配进 create_runtime_app。 + +client 侧(``A2ASpaceClient`` 动态发现)见 goal-06。 +""" + +from ksadk.a2a.bootstrap import A2ACheckpointStore, AgentEngineA2ABootstrap, RuntimeA2AMetadata +from ksadk.a2a.card import ( + A2A_PROTOCOL_VERSION, + JSONRPC_PATH, + REST_PATH_PREFIX, + build_agent_card, +) +from ksadk.a2a.context_store import ( + A2AContextIdentity, + A2AContextStore, + SQLiteA2AContextStore, +) +from ksadk.a2a.control_plane import ( + A2A_INTERNAL_ACTIONS, + A2A_INTERNAL_PATH_PREFIX, + ENV_A2A_CONTROL_PLANE_URL, + ENV_A2A_TOKEN_DIR, + A2AControlPlane, + A2AControlPlaneError, + A2AInternalAction, + A2AOperation, + A2ARoute, + A2ARouteInterface, + A2ATarget, + CredentialInjection, + FileWorkloadTokenProvider, + InternalA2AControlPlaneClient, + PreparedA2AOperation, + RemoteTaskReference, + WorkloadTokenProvider, + build_a2a_internal_action_path, +) +from ksadk.a2a.event_adapter import A2AEventAdapter +from ksadk.a2a.executor import A2ARuntimeExecutor +from ksadk.a2a.external_transport import ( + ERR_VPC_EGRESS_DIALER_REQUIRED, + A2AExternalTransport, + A2ARouteOpener, + A2ATransportLease, + CallableA2ARouteOpener, + GuardedA2AExternalTransport, + RuntimeLocalA2AExternalTransport, +) +from ksadk.a2a.identity import ( + A2AGatewayIdentityMiddleware, + A2AIngressIdentity, + A2AIngressTargetBinding, + A2ATrustedIdentityResolver, + CallableGatewayIdentityVerifier, + CallableGatewayProbeVerifier, + GatewayIdentityVerifier, + GatewayProbeVerifier, +) +from ksadk.a2a.langgraph import ( + A2AStreamEvent, + stream_a2a_agent, + stream_a2a_agent_events, + stream_a2a_agent_to_writer, +) +from ksadk.a2a.resume_store import ( + A2AResumePayloadKind, + A2AResumeState, + A2AResumeStateStore, + InMemoryA2AResumeStateStore, + SQLiteA2AResumeStateStore, +) +from ksadk.a2a.routes import A2AConfig, add_a2a_protocol_routes +from ksadk.a2a.server import A2AProtocolServer +from ksadk.a2a.space_client import ( + ENV_A2A_ENABLE_PUBLIC_EGRESS, + ENV_A2A_SPACE_IDS, + ERR_PUBLIC_EGRESS_DISABLED, + A2APlatformTask, + A2ASpaceClient, + DiscoveredAgent, + SpaceAgentPage, +) +from ksadk.a2a.task_adapter import A2ARuntimeTaskAdapter +from ksadk.a2a.task_event_dispatcher import A2ATaskEventDispatcher, A2ATaskEventSink +from ksadk.a2a.task_event_outbox import ( + DEFAULT_A2A_EVENT_OUTBOX_PATH, + ENV_A2A_EVENT_OUTBOX_PATH, + A2ATaskEventBatch, + A2ATaskEventOutbox, + InMemoryA2ATaskEventOutbox, + SQLiteA2ATaskEventOutbox, +) +from ksadk.a2a.task_store import A2A_TASK_TABLE, build_a2a_task_store __all__ = [ - "AgentCardBuilder", - "KsA2AServer", - "KsAgentExecutor", - "RemoteA2AAgent", - "RemoteA2AClient", - "to_a2a", + "A2AConfig", + "A2ACheckpointStore", + "A2AContextIdentity", + "A2AContextStore", + "A2AControlPlane", + "A2AControlPlaneError", + "A2AInternalAction", + "A2A_INTERNAL_ACTIONS", + "A2A_INTERNAL_PATH_PREFIX", + "A2AOperation", + "A2AEventAdapter", + "A2AExternalTransport", + "A2AGatewayIdentityMiddleware", + "A2AIngressIdentity", + "A2AIngressTargetBinding", + "A2APlatformTask", + "A2AProtocolServer", + "A2ARuntimeExecutor", + "A2ARuntimeTaskAdapter", + "A2AResumeState", + "A2AResumePayloadKind", + "A2AResumeStateStore", + "A2ASpaceClient", + "A2AStreamEvent", + "A2A_PROTOCOL_VERSION", + "A2ARoute", + "A2ARouteOpener", + "A2ARouteInterface", + "A2A_TASK_TABLE", + "A2ATarget", + "A2ATaskEventBatch", + "A2ATaskEventDispatcher", + "A2ATaskEventSink", + "A2ATaskEventOutbox", + "A2ATransportLease", + "A2ATrustedIdentityResolver", + "AgentEngineA2ABootstrap", + "CallableA2ARouteOpener", + "CallableGatewayIdentityVerifier", + "CallableGatewayProbeVerifier", + "CredentialInjection", + "DiscoveredAgent", + "ERR_VPC_EGRESS_DIALER_REQUIRED", + "DEFAULT_A2A_EVENT_OUTBOX_PATH", + "ENV_A2A_CONTROL_PLANE_URL", + "ENV_A2A_ENABLE_PUBLIC_EGRESS", + "ENV_A2A_EVENT_OUTBOX_PATH", + "ENV_A2A_SPACE_IDS", + "ENV_A2A_TOKEN_DIR", + "ERR_PUBLIC_EGRESS_DISABLED", + "FileWorkloadTokenProvider", + "GatewayIdentityVerifier", + "GatewayProbeVerifier", + "GuardedA2AExternalTransport", + "InMemoryA2ATaskEventOutbox", + "InMemoryA2AResumeStateStore", + "JSONRPC_PATH", + "InternalA2AControlPlaneClient", + "PreparedA2AOperation", + "RemoteTaskReference", + "REST_PATH_PREFIX", + "SpaceAgentPage", + "SQLiteA2ATaskEventOutbox", + "SQLiteA2AContextStore", + "SQLiteA2AResumeStateStore", + "RuntimeA2AMetadata", + "RuntimeLocalA2AExternalTransport", + "WorkloadTokenProvider", + "add_a2a_protocol_routes", + "build_a2a_task_store", + "build_a2a_internal_action_path", + "build_agent_card", + "stream_a2a_agent", + "stream_a2a_agent_events", + "stream_a2a_agent_to_writer", ] diff --git a/ksadk/a2a/bootstrap.py b/ksadk/a2a/bootstrap.py new file mode 100644 index 00000000..9a08ddfa --- /dev/null +++ b/ksadk/a2a/bootstrap.py @@ -0,0 +1,334 @@ +"""AgentEngine product composition root for managed inbound and outbound A2A.""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Protocol, Sequence +from urllib.parse import urlsplit + +import httpx +from a2a.server.tasks import TaskStore +from a2a.types import AgentSkill +from fastapi import FastAPI + +from ksadk.a2a.context_store import A2AContextStore +from ksadk.a2a.external_transport import RuntimeLocalA2AExternalTransport +from ksadk.a2a.identity import ( + A2AGatewayIdentityMiddleware, + A2AIngressTargetBinding, + A2ATrustedIdentityResolver, + GatewayIdentityVerifier, + GatewayProbeVerifier, +) +from ksadk.a2a.ids import require_a2a_resource_id +from ksadk.a2a.resume_store import A2AResumeStateStore +from ksadk.a2a.routes import A2AConfig, add_a2a_protocol_routes +from ksadk.a2a.task_adapter import A2ARuntimeTaskAdapter +from ksadk.a2a.task_event_dispatcher import A2ATaskEventDispatcher +from ksadk.a2a.task_event_outbox import A2ATaskEventOutbox +from ksadk.a2a.task_store import A2AOwnerContextBuilder +from ksadk.runtime.adapter import RuntimeAdapter + + +@dataclass(frozen=True) +class RuntimeA2AMetadata: + account_id: str + tenant_id: str + agent_id: str + a2a_agent_id: str + runtime_id: str + internal_base_url: str + name: str + version: str + skills: Sequence[AgentSkill] + description: str = "" + + def validate(self) -> None: + values = { + "account_id": self.account_id, + "agent_id": self.agent_id, + "a2a_agent_id": self.a2a_agent_id, + "runtime_id": self.runtime_id, + "internal_base_url": self.internal_base_url, + "name": self.name, + "version": self.version, + } + missing = [name for name, value in values.items() if not str(value).strip()] + if missing: + raise ValueError(f"RuntimeA2AMetadata is missing required fields: {', '.join(missing)}") + if not self.agent_id.startswith("ar-"): + raise ValueError("RuntimeA2AMetadata.agent_id must be an ar-* Agent ID") + require_a2a_resource_id( + self.a2a_agent_id, + "a2a-agent-", + field_name="RuntimeA2AMetadata.a2a_agent_id", + ) + for field_name, limit in ( + ("account_id", 64), + ("tenant_id", 64), + ("agent_id", 64), + ("a2a_agent_id", 64), + ("runtime_id", 64), + ("name", 128), + ("description", 1024), + ("version", 64), + ): + if len(str(getattr(self, field_name))) > limit: + raise ValueError(f"RuntimeA2AMetadata.{field_name} exceeds {limit} characters") + if len(self.skills) > 100 or not all( + isinstance(skill, AgentSkill) for skill in self.skills + ): + raise ValueError( + "RuntimeA2AMetadata.skills must contain at most 100 AgentSkill objects" + ) + parsed = urlsplit(self.internal_base_url) + try: + port = parsed.port + except ValueError as exc: + raise ValueError( + "RuntimeA2AMetadata.internal_base_url must be an absolute HTTP(S) origin" + ) from exc + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or parsed.netloc.rsplit("@", 1)[-1].endswith(":") + or port is not None and not 1 <= port <= 65535 + ): + raise ValueError( + "RuntimeA2AMetadata.internal_base_url must be an absolute HTTP(S) origin" + ) + + +class A2ACheckpointStore(Protocol): + """Durable checkpoint backend readiness contract owned by the Runtime adapter.""" + + async def initialize(self) -> None: ... + + +class AgentEngineA2ABootstrap: + """Single product-owned composition root for managed A2A Runtime dependencies.""" + + def __init__( + self, + *, + runtime_metadata: RuntimeA2AMetadata, + task_store: TaskStore | None = None, + context_store: A2AContextStore | None = None, + checkpoint_store: A2ACheckpointStore | None = None, + resume_state_store: A2AResumeStateStore | None = None, + gateway_identity_verifier: GatewayIdentityVerifier | None = None, + gateway_probe_verifier: GatewayProbeVerifier | None = None, + external_transport: RuntimeLocalA2AExternalTransport | None = None, + control_plane: Any | None = None, + hosted_http_client: httpx.AsyncClient | None = None, + event_outbox: A2ATaskEventOutbox | None = None, + inbound_enabled: bool = True, + outbound_enabled: bool = True, + public_egress_enabled: bool = False, + outbox_retry_interval_seconds: float = 1.0, + ) -> None: + runtime_metadata.validate() + required: dict[str, Any] = {} + if outbound_enabled: + required.update( + { + "control_plane": control_plane, + "hosted_http_client": hosted_http_client, + "event_outbox": event_outbox, + } + ) + if inbound_enabled: + required.update( + { + "task_store": task_store, + "context_store": context_store, + "checkpoint_store": checkpoint_store, + "resume_state_store": resume_state_store, + "gateway_identity_verifier": gateway_identity_verifier, + "gateway_probe_verifier": gateway_probe_verifier, + } + ) + missing = [name for name, value in required.items() if value is None] + if missing: + raise ValueError( + "AgentEngineA2ABootstrap requires: " + ", ".join(missing) + ) + if external_transport is not None and not isinstance( + external_transport, RuntimeLocalA2AExternalTransport + ): + raise TypeError( + "AgentEngineA2ABootstrap requires RuntimeLocalA2AExternalTransport " + "for external_public routes" + ) + if not outbound_enabled and external_transport is not None: + raise ValueError("external_transport requires outbound_enabled=True") + if inbound_enabled: + _require_initializable(task_store, "task_store") + _require_initializable(context_store, "context_store") + _require_initializable(checkpoint_store, "checkpoint_store") + _require_initializable(resume_state_store, "resume_state_store") + self.runtime_metadata = runtime_metadata + self._target_binding = A2AIngressTargetBinding( + account_id=runtime_metadata.account_id, + tenant_id=runtime_metadata.tenant_id, + agent_id=runtime_metadata.agent_id, + runtime_id=runtime_metadata.runtime_id, + a2a_agent_id=runtime_metadata.a2a_agent_id, + ) + self.inbound_enabled = inbound_enabled + self.outbound_enabled = outbound_enabled + self.public_egress_enabled = public_egress_enabled + self._task_store = task_store + self._context_store = context_store + self._checkpoint_store = checkpoint_store + self._resume_state_store = resume_state_store + self._gateway_identity_verifier = gateway_identity_verifier + self._gateway_probe_verifier = gateway_probe_verifier + self._external_transport = external_transport + self._control_plane = control_plane + self._hosted_http_client = hosted_http_client + self._dispatcher = ( + A2ATaskEventDispatcher( + _require(event_outbox, "event_outbox"), + _require(control_plane, "control_plane"), + retry_interval_seconds=outbox_retry_interval_seconds, + ) + if outbound_enabled + else None + ) + self._mounted = False + self._server: Any = None + + @classmethod + def from_platform(cls, **kwargs: Any) -> "AgentEngineA2ABootstrap": + """Explicit product factory; dependencies are injected by the platform layer.""" + + return cls(**kwargs) + + @property + def event_dispatcher(self) -> A2ATaskEventDispatcher: + if self._dispatcher is None: + raise RuntimeError("A2A outbound client is disabled for this Runtime") + return self._dispatcher + + @property + def server(self) -> Any: + return self._server + + def client_for_space(self, space_id: str): + """Create a Space-scoped client sharing Runtime identity, clients, and outbox.""" + + from ksadk.a2a.space_client import A2ASpaceClient + + if not self.outbound_enabled: + raise RuntimeError("A2A outbound client is disabled for this Runtime") + return A2ASpaceClient( + space_id, + _require(self._control_plane, "control_plane"), + egress_enabled=self.public_egress_enabled, + httpx_client=_require(self._hosted_http_client, "hosted_http_client"), + external_transport=self._external_transport, + event_dispatcher=self.event_dispatcher, + ) + + def mount( + self, + app: FastAPI, + *, + runner: Any, + runtime_adapter: RuntimeAdapter, + runtime_type: str, + ) -> Any | None: + """Mount exactly one managed inbound A2A server into an app.""" + + if self._mounted: + raise RuntimeError("AgentEngineA2ABootstrap may only be mounted once") + self._mounted = True + app.state.a2a_bootstrap = self + if not self.inbound_enabled: + return None + app.add_middleware( + A2AGatewayIdentityMiddleware, + verifier=_require(self._gateway_identity_verifier, "gateway_identity_verifier"), + probe_verifier=_require(self._gateway_probe_verifier, "gateway_probe_verifier"), + target_binding=self._target_binding, + ) + config = A2AConfig( + enabled=True, + base_url=self.runtime_metadata.internal_base_url, + agent_name=self.runtime_metadata.name, + description=self.runtime_metadata.description, + version=self.runtime_metadata.version, + skills=self.runtime_metadata.skills, + create_table=False, + ) + task_adapter = A2ARuntimeTaskAdapter( + runtime_adapter, + runtime_type=runtime_type, + context_store=_require(self._context_store, "context_store"), + resume_state_store=_require(self._resume_state_store, "resume_state_store"), + ) + self._server = add_a2a_protocol_routes( + app, + runner, + config, + task_adapter=task_adapter, + task_store=_require(self._task_store, "task_store"), + context_builder=A2AOwnerContextBuilder( + identity_resolver=A2ATrustedIdentityResolver( + target_binding=self._target_binding, + ), + allow_unverified_identity=False, + ), + ) + return self._server + + async def start(self) -> None: + if self.inbound_enabled: + await _initialize_required(_require(self._task_store, "task_store"), "task_store") + await _initialize_required( + _require(self._context_store, "context_store"), "context_store" + ) + await _initialize_required( + _require(self._checkpoint_store, "checkpoint_store"), "checkpoint_store" + ) + await _initialize_required( + _require(self._resume_state_store, "resume_state_store"), + "resume_state_store", + ) + if self._dispatcher is not None: + await self._dispatcher.start() + + async def stop(self, *, flush_timeout_seconds: float = 5.0) -> None: + if self._dispatcher is not None: + await self._dispatcher.stop(flush_timeout_seconds=flush_timeout_seconds) + + +def _require_initializable(value: Any, name: str) -> None: + initialize = getattr(value, "initialize", None) + if not callable(initialize): + raise TypeError(f"{name} must implement async initialize()") + + +async def _initialize_required(value: Any, name: str) -> None: + _require_initializable(value, name) + initialize = value.initialize + result = initialize() + if not inspect.isawaitable(result): + raise TypeError(f"{name}.initialize() must return an awaitable") + await result + + +def _require(value: Any, name: str) -> Any: + if value is None: + raise RuntimeError(f"AgentEngineA2ABootstrap is missing {name}") + return value + + +__all__ = ["A2ACheckpointStore", "AgentEngineA2ABootstrap", "RuntimeA2AMetadata"] diff --git a/ksadk/a2a/card.py b/ksadk/a2a/card.py new file mode 100644 index 00000000..a8fde594 --- /dev/null +++ b/ksadk/a2a/card.py @@ -0,0 +1,120 @@ +"""A2A AgentCard 构造 (goal-05,wire 1.0)。 + +契约(`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, +) + +#: 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", + "REST_PATH_PREFIX", + "build_agent_card", +] diff --git a/ksadk/a2a/context_store.py b/ksadk/a2a/context_store.py new file mode 100644 index 00000000..52be75fb --- /dev/null +++ b/ksadk/a2a/context_store.py @@ -0,0 +1,203 @@ +"""Durable owner-scoped mapping from A2A context IDs to Runtime sessions.""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import sqlite3 +import uuid +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class A2AContextIdentity: + """Verified caller identity used as the context-mapping ownership boundary.""" + + account_id: str + tenant_id: str + caller_principal_type: str + caller_principal_id: str + + def canonical_owner(self) -> str: + values = ( + self.account_id.strip(), + self.tenant_id.strip(), + self.caller_principal_type.strip(), + self.caller_principal_id.strip(), + ) + if not values[0] or not values[2] or not values[3]: + raise ValueError( + "A2A context identity requires verified account, type, and principal" + ) + return "\x1f".join(values) + + +class A2AContextStore(ABC): + """Maps a verified external context to an internal Runtime session ID.""" + + @abstractmethod + async def initialize(self) -> None: + """Verify durable storage is ready before inbound A2A starts serving.""" + raise NotImplementedError + + @abstractmethod + async def resolve_or_create( + self, + identity: A2AContextIdentity, + external_context_id: str, + *, + isolation_scope: str | None = None, + ) -> str: + raise NotImplementedError + + @abstractmethod + async def get( + self, + identity: A2AContextIdentity, + external_context_id: str, + *, + isolation_scope: str | None = None, + ) -> str | None: + raise NotImplementedError + + +class SQLiteA2AContextStore(A2AContextStore): + """Crash-safe local adapter for tests and single-replica Runtime deployments. + + Product deployments may supply a shared durable implementation of + :class:`A2AContextStore`; callers do not depend on this SQLite schema. + """ + + def __init__(self, path: str | os.PathLike[str]) -> None: + self._path = Path(path) + self._initialized = False + self._init_lock = asyncio.Lock() + + @property + def path(self) -> Path: + return self._path + + async def initialize(self) -> None: + if self._initialized: + return + async with self._init_lock: + if self._initialized: + return + await asyncio.to_thread(self._initialize_sync) + self._initialized = True + + async def resolve_or_create( + self, + identity: A2AContextIdentity, + external_context_id: str, + *, + isolation_scope: str | None = None, + ) -> str: + await self.initialize() + key = self._mapping_key(identity, external_context_id, isolation_scope) + return await asyncio.to_thread(self._resolve_or_create_sync, key) + + async def get( + self, + identity: A2AContextIdentity, + external_context_id: str, + *, + isolation_scope: str | None = None, + ) -> str | None: + await self.initialize() + key = self._mapping_key(identity, external_context_id, isolation_scope) + return await asyncio.to_thread(self._get_sync, key) + + def _initialize_sync(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self._path, timeout=5) + try: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA synchronous=FULL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS ksadk_a2a_context_mappings ( + schema_version INTEGER NOT NULL, + owner_key_sha256 TEXT NOT NULL, + external_context_sha256 TEXT NOT NULL, + isolation_scope TEXT NOT NULL, + internal_session_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(owner_key_sha256, external_context_sha256, isolation_scope) + ) + """ + ) + connection.commit() + finally: + connection.close() + os.chmod(self._path, 0o600) + + @staticmethod + def _mapping_key( + identity: A2AContextIdentity, + external_context_id: str, + isolation_scope: str | None, + ) -> tuple[str, str, str]: + context = external_context_id.strip() + if not context: + raise ValueError("external_context_id must be non-empty") + owner_hash = hashlib.sha256(identity.canonical_owner().encode("utf-8")).hexdigest() + context_hash = hashlib.sha256(context.encode("utf-8")).hexdigest() + return owner_hash, context_hash, str(isolation_scope or "") + + def _resolve_or_create_sync(self, key: tuple[str, str, str]) -> str: + owner_hash, context_hash, scope = key + connection = sqlite3.connect(self._path, timeout=5, isolation_level=None) + try: + connection.execute("PRAGMA synchronous=FULL") + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + """ + SELECT internal_session_id + FROM ksadk_a2a_context_mappings + WHERE owner_key_sha256 = ? AND external_context_sha256 = ? AND isolation_scope = ? + """, + key, + ).fetchone() + if row is not None: + connection.execute("COMMIT") + return str(row[0]) + session_id = f"a2a-session-{uuid.uuid4().hex}" + connection.execute( + """ + INSERT INTO ksadk_a2a_context_mappings( + schema_version, owner_key_sha256, external_context_sha256, + isolation_scope, internal_session_id + ) VALUES (1, ?, ?, ?, ?) + """, + (owner_hash, context_hash, scope, session_id), + ) + connection.execute("COMMIT") + return session_id + except BaseException: + connection.execute("ROLLBACK") + raise + finally: + connection.close() + + def _get_sync(self, key: tuple[str, str, str]) -> str | None: + connection = sqlite3.connect(self._path, timeout=5) + try: + row = connection.execute( + """ + SELECT internal_session_id + FROM ksadk_a2a_context_mappings + WHERE owner_key_sha256 = ? AND external_context_sha256 = ? AND isolation_scope = ? + """, + key, + ).fetchone() + return str(row[0]) if row is not None else None + finally: + connection.close() + + +__all__ = ["A2AContextIdentity", "A2AContextStore", "SQLiteA2AContextStore"] diff --git a/ksadk/a2a/control_plane.py b/ksadk/a2a/control_plane.py new file mode 100644 index 00000000..d4e5b17e --- /dev/null +++ b/ksadk/a2a/control_plane.py @@ -0,0 +1,818 @@ +"""Typed client for the AgentEngine A2A runtime control-plane contract.""" + +from __future__ import annotations + +import base64 +import binascii +import errno +import json +import os +import stat +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, get_args + +import httpx +from a2a.types import AgentCard +from google.protobuf.json_format import ParseDict, ParseError + +from ksadk.a2a.ids import require_a2a_resource_id + +ENV_A2A_CONTROL_PLANE_URL = "KSADK_A2A_CONTROL_PLANE_URL" +ENV_A2A_TOKEN_DIR = "KSADK_A2A_TOKEN_DIR" +DEFAULT_A2A_TOKEN_DIR = "/var/run/secrets/agentengine/a2a" +MAX_WORKLOAD_TOKEN_BYTES = 16 * 1024 + +AUDIENCE_REGISTRY = "a2a-registry" +AUDIENCE_TASK_SINK = "a2a-task-sink" +AUDIENCE_CREDENTIAL_BROKER = "credential-broker" +AUDIENCE_GATEWAY = "a2a-gateway" + +A2AOperation = Literal[ + "send_message", + "get_task", + "subscribe_to_task", + "cancel_task", +] +A2A_OPERATIONS = frozenset(get_args(A2AOperation)) + +A2AInternalAction = Literal[ + "ListA2ASpaceAgents", + "PrepareA2ACall", + "PrepareA2ATaskOperation", + "BindA2ARemoteTask", + "AppendA2ATaskEvents", + "ResolveA2ACredential", +] +A2A_INTERNAL_ACTIONS = frozenset(get_args(A2AInternalAction)) +A2A_INTERNAL_PATH_PREFIX = "/agentengine/internal/v1/a2a" + + +def build_a2a_internal_action_path(action: A2AInternalAction) -> str: + """Return the explicitly registered Runtime internal Action path.""" + + if action not in A2A_INTERNAL_ACTIONS: + raise ValueError(f"unsupported A2A internal Action: {action}") + return f"{A2A_INTERNAL_PATH_PREFIX}/{action}" + + +class A2AControlPlaneError(RuntimeError): + """Stable error returned by an AgentEngine A2A Action.""" + + def __init__( + self, + *, + code: int, + message: str, + error_code: str, + retryable: bool = False, + field: str | None = None, + details: dict[str, Any] | None = None, + request_id: str = "", + action: str = "", + ) -> None: + super().__init__(f"{error_code}: {message}" if error_code else message) + self.code = code + self.message = message + self.error_code = error_code + self.retryable = retryable + self.field = field + self.details = dict(details or {}) + self.request_id = request_id + self.action = action + + +class WorkloadTokenProvider(ABC): + """Returns a short-lived workload token for one exact audience.""" + + @abstractmethod + def get_token(self, audience: str) -> str: + raise NotImplementedError + + +class FileWorkloadTokenProvider(WorkloadTokenProvider): + """Reads audience-specific projected JWT files before every request.""" + + def __init__(self, token_dir: str | os.PathLike[str] | None = None) -> None: + configured = token_dir or os.getenv(ENV_A2A_TOKEN_DIR) or DEFAULT_A2A_TOKEN_DIR + self._token_dir = Path(configured) + + def get_token(self, audience: str) -> str: + if not audience or "/" in audience or audience in {".", ".."}: + raise ValueError(f"invalid workload token audience: {audience!r}") + path = self._token_dir / f"{audience}.jwt" + if path.is_symlink(): + raise RuntimeError( + f"workload token for audience {audience!r} must not be a symlink: {path}" + ) + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + descriptor = os.open(path, flags) + except OSError as exc: + if exc.errno == errno.ELOOP: + raise RuntimeError( + f"workload token for audience {audience!r} must not be a symlink: {path}" + ) from exc + raise RuntimeError(f"missing workload token for audience {audience!r}: {path}") from exc + try: + token_stat = os.fstat(descriptor) + if not stat.S_ISREG(token_stat.st_mode): + raise RuntimeError( + f"workload token for audience {audience!r} is not a regular file: {path}" + ) + if token_stat.st_size > MAX_WORKLOAD_TOKEN_BYTES: + raise RuntimeError( + f"workload token for audience {audience!r} exceeds 16 KiB: {path}" + ) + if stat.S_IMODE(token_stat.st_mode) != 0o400: + raise RuntimeError( + f"workload token for audience {audience!r} must have mode 0400: {path}" + ) + with os.fdopen(descriptor, "rb", closefd=False) as token_file: + raw_token = token_file.read(MAX_WORKLOAD_TOKEN_BYTES + 1) + if len(raw_token) > MAX_WORKLOAD_TOKEN_BYTES: + raise RuntimeError( + f"workload token for audience {audience!r} exceeds 16 KiB: {path}" + ) + token = raw_token.decode("ascii").strip() + except UnicodeError as exc: + raise RuntimeError( + f"workload token for audience {audience!r} must be ASCII: {path}" + ) from exc + finally: + os.close(descriptor) + if not token: + raise RuntimeError(f"empty workload token for audience {audience!r}: {path}") + self._validate_expiry(token, audience=audience, path=path) + return token + + @staticmethod + def _validate_expiry(token: str, *, audience: str, path: Path) -> None: + parts = token.split(".") + if len(parts) != 3: + raise RuntimeError(f"workload token for audience {audience!r} is not a JWT: {path}") + try: + payload_segment = parts[1] + "=" * (-len(parts[1]) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_segment).decode("utf-8")) + expires_at = int(payload["exp"]) + except (binascii.Error, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError( + f"workload token for audience {audience!r} has no valid exp claim: {path}" + ) from exc + token_audience = payload.get("aud") + accepted_audiences = ( + {str(item) for item in token_audience} + if isinstance(token_audience, list) + else {str(token_audience)} + ) + if audience not in accepted_audiences: + raise RuntimeError(f"workload token audience mismatch for {audience!r}: {path}") + if expires_at <= int(time.time()): + raise RuntimeError(f"workload token for audience {audience!r} is expired: {path}") + + +@dataclass(frozen=True) +class A2ARouteInterface: + url: str + protocol_binding: str + protocol_version: str + + +@dataclass(frozen=True) +class A2ARoute: + kind: str + interface: A2ARouteInterface + + +@dataclass(frozen=True) +class A2ATarget: + agent_id: str + version_id: str + card_sha256: str + + +@dataclass(frozen=True) +class RemoteTaskReference: + remote_task_id: str + remote_context_id: str | None = None + + +@dataclass(frozen=True) +class PreparedA2AOperation: + platform_task_id: str + target: A2ATarget + route: A2ARoute + call_permit: str + call_permit_expires_at: str + credential_handle: str | None = None + remote_task: RemoteTaskReference | None = None + + +@dataclass(frozen=True) +class CredentialInjection: + headers: dict[str, str] = field(default_factory=dict) + query: dict[str, str] = field(default_factory=dict) + cookies: dict[str, str] = field(default_factory=dict) + expires_at: str | None = None + + +@dataclass +class DiscoveredAgent: + agent_id: str + version_id: str + source: str + agent_card: AgentCard + card_sha256: str = "" + callable: bool = True + blocked_reason: str | None = None + route_kind: str = "" + + +@dataclass +class SpaceAgentPage: + agents: list[DiscoveredAgent] = field(default_factory=list) + etag: str | None = None + next_cursor: str | None = None + not_modified: bool = False + + +class A2AControlPlane(ABC): + """Control-plane seam required by the product A2ASpaceClient.""" + + @abstractmethod + 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: + raise NotImplementedError + + @abstractmethod + 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: + raise NotImplementedError + + @abstractmethod + 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, + ) -> PreparedA2AOperation: + raise NotImplementedError + + @abstractmethod + 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]: + raise NotImplementedError + + @abstractmethod + async def append_task_events( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> dict[str, Any]: + raise NotImplementedError + + @abstractmethod + async def resolve_credential( + self, + *, + platform_task_id: str, + credential_handle: str | None, + call_permit: str, + ) -> CredentialInjection: + raise NotImplementedError + + @abstractmethod + def gateway_token(self) -> str: + raise NotImplementedError + + +class InternalA2AControlPlaneClient(A2AControlPlane): + """Workload-authenticated client for Runtime internal A2A Actions.""" + + def __init__( + self, + base_url: str, + *, + token_provider: WorkloadTokenProvider | None = None, + httpx_client: httpx.AsyncClient | None = None, + timeout: float = 15.0, + ) -> None: + if not base_url: + raise ValueError( + f"InternalA2AControlPlaneClient requires {ENV_A2A_CONTROL_PLANE_URL}" + ) + self._base_url = base_url.rstrip("/") + self._token_provider = token_provider or FileWorkloadTokenProvider() + self._client = httpx_client + self._timeout = timeout + + async def _post( + self, + action: A2AInternalAction, + *, + audience: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + token = self._token_provider.get_token(audience) + client = self._client or httpx.AsyncClient(timeout=self._timeout) + owns_client = self._client is None + try: + try: + response = await client.post( + f"{self._base_url}{build_a2a_internal_action_path(action)}", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError as exc: + raise A2AControlPlaneError( + code=503, + message="A2A control plane is unavailable", + error_code="A2A_CONTROL_PLANE_UNAVAILABLE", + retryable=True, + action=action, + ) from exc + try: + envelope = response.json() + except ValueError: + unavailable = response.status_code >= 500 + raise A2AControlPlaneError( + code=response.status_code if unavailable else 502, + message="A2A control plane returned a non-JSON response", + error_code=( + "A2A_CONTROL_PLANE_UNAVAILABLE" + if unavailable + else "A2A_CONTROL_PLANE_INVALID_RESPONSE" + ), + retryable=unavailable, + action=action, + ) + if not isinstance(envelope, dict): + raise A2AControlPlaneError( + code=response.status_code if response.is_error else 502, + message="A2A control plane response must be an object", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + retryable=response.status_code >= 500, + action=action, + ) + try: + action_code = int(envelope.get("Code") or 0) + except (TypeError, ValueError) as exc: + raise A2AControlPlaneError( + code=502, + message="A2A control plane response Code must be an integer", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + request_id=str(envelope.get("RequestId") or ""), + action=action, + ) from exc + if response.is_error or action_code != 0: + self._raise_action_error(response, envelope, action) + data = envelope.get("Data") + if not isinstance(data, dict): + raise A2AControlPlaneError( + code=response.status_code, + message="A2A control plane response Data must be an object", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + request_id=str(envelope.get("RequestId") or ""), + action=action, + ) + return data + finally: + if owns_client: + await client.aclose() + + @staticmethod + def _raise_action_error( + response: httpx.Response, + envelope: dict[str, Any], + action: str, + ) -> None: + raw_data = envelope.get("Data") + data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} + raise A2AControlPlaneError( + code=int(envelope.get("Code") or response.status_code), + message=str(envelope.get("Message") or response.reason_phrase or "A2A action failed"), + error_code=str(data.get("ErrorCode") or "A2A_CONTROL_PLANE_ERROR"), + retryable=bool(data.get("Retryable", response.status_code >= 500)), + field=str(data.get("Field")) if data.get("Field") is not None else None, + details=data.get("Details") if isinstance(data.get("Details"), dict) else {}, + request_id=str(envelope.get("RequestId") or ""), + action=str(envelope.get("Action") or action), + ) + + 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: + payload: dict[str, Any] = {"A2ASpaceId": space_id} + if prompt: + payload["Prompt"] = prompt + if skill_id: + payload["SkillId"] = skill_id + if include_blocked: + payload["IncludeBlocked"] = True + if if_none_match: + payload["IfNoneMatch"] = if_none_match + if cursor: + payload["Cursor"] = cursor + if page_size != 50: + payload["PageSize"] = page_size + data = await self._post( + "ListA2ASpaceAgents", + audience=AUDIENCE_REGISTRY, + payload=payload, + ) + return SpaceAgentPage( + agents=[_discovered_agent_from_wire(item) for item in data.get("Agents") or []], + etag=_optional_str(data.get("ETag")), + next_cursor=_optional_str(data.get("NextCursor")), + not_modified=bool(data.get("NotModified", False)), + ) + + 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: + payload: dict[str, Any] = { + "A2ASpaceId": space_id, + "TargetA2AAgentId": target_agent_id, + "MessageId": message_id, + "MessageSha256": message_sha256, + "IdempotencyToken": idempotency_token, + } + if expected_version_id: + payload["ExpectedVersionId"] = expected_version_id + data = await self._post("PrepareA2ACall", audience=AUDIENCE_REGISTRY, payload=payload) + return _prepared_operation_from_wire(data) + + 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, + ) -> PreparedA2AOperation: + _validate_task_operation_request( + operation=operation, + message_id=message_id, + message_sha256=message_sha256, + idempotency_token=idempotency_token, + ) + payload: dict[str, Any] = {"A2ATaskId": platform_task_id, "Operation": operation} + if message_id: + payload["MessageId"] = message_id + if message_sha256: + payload["MessageSha256"] = message_sha256 + if idempotency_token: + payload["IdempotencyToken"] = idempotency_token + data = await self._post( + "PrepareA2ATaskOperation", + audience=AUDIENCE_REGISTRY, + payload=payload, + ) + return _prepared_operation_from_wire(data) + + 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]: + payload: dict[str, Any] = { + "A2ATaskId": platform_task_id, + "RemoteTaskId": remote_task_id, + "ObservedAt": observed_at, + } + if remote_context_id: + payload["RemoteContextId"] = remote_context_id + return await self._post( + "BindA2ARemoteTask", + audience=AUDIENCE_TASK_SINK, + payload=payload, + ) + + async def append_task_events( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> dict[str, Any]: + return await self._post( + "AppendA2ATaskEvents", + audience=AUDIENCE_TASK_SINK, + payload={"A2ATaskId": platform_task_id, "Events": events}, + ) + + async def resolve_credential( + self, + *, + platform_task_id: str, + credential_handle: str | None, + call_permit: str, + ) -> CredentialInjection: + data = await self._post( + "ResolveA2ACredential", + audience=AUDIENCE_CREDENTIAL_BROKER, + payload={ + "A2ATaskId": platform_task_id, + "CredentialHandle": credential_handle, + "CallPermit": call_permit, + }, + ) + raw_injection = data.get("Injection") + injection: dict[str, Any] = raw_injection if isinstance(raw_injection, dict) else {} + return CredentialInjection( + headers=_credential_string_map( + injection.get("Headers"), field_name="Injection.Headers" + ), + query=_credential_string_map(injection.get("Query"), field_name="Injection.Query"), + cookies=_credential_string_map( + injection.get("Cookies"), field_name="Injection.Cookies" + ), + expires_at=_optional_str(data.get("ExpiresAt")), + ) + + def gateway_token(self) -> str: + return self._token_provider.get_token(AUDIENCE_GATEWAY) + + +def _credential_string_map(value: Any, *, field_name: str) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict) or any( + not isinstance(key, str) or not isinstance(item, str) for key, item in value.items() + ): + raise A2AControlPlaneError( + code=502, + message=f"control-plane response field {field_name} must be a string map", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field=field_name, + ) + return dict(value) + + +def _validate_task_operation_request( + *, + operation: str, + message_id: str | None, + message_sha256: str | None, + idempotency_token: str | None, +) -> None: + if operation not in A2A_OPERATIONS: + raise ValueError(f"unsupported A2A task operation: {operation!r}") + if operation == "send_message": + if not message_id or not message_sha256 or not idempotency_token: + raise ValueError( + "send_message requires message_id, message_sha256, and idempotency_token" + ) + return + if message_id or message_sha256: + raise ValueError(f"{operation} does not accept message fields") + if operation == "cancel_task": + if not idempotency_token: + raise ValueError("cancel_task requires idempotency_token") + return + if idempotency_token: + raise ValueError(f"{operation} does not accept idempotency_token") + + +def _optional_str(value: Any) -> str | None: + return str(value) if value is not None else None + + +def _required_str(value: Any, *, field_name: str, prefix: str | None = None) -> str: + result = str(value or "") + if not result: + raise A2AControlPlaneError( + code=502, + message=f"control-plane response field {field_name} is missing or invalid", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field=field_name, + ) + if prefix is not None: + try: + require_a2a_resource_id(result, prefix, field_name=field_name) + except ValueError as exc: + raise A2AControlPlaneError( + code=502, + message=f"control-plane response field {field_name} has an invalid resource ID", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field=field_name, + ) from exc + return result + + +def _agent_card_from_wire(payload: Any) -> AgentCard: + if not isinstance(payload, dict): + raise A2AControlPlaneError( + code=502, + message="AgentCard must be an object", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + ) + try: + return ParseDict(payload, AgentCard()) + except (ParseError, TypeError, ValueError) as exc: + raise A2AControlPlaneError( + code=502, + message="AgentCard does not match the A2A 1.0 schema", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="AgentCard", + ) from exc + + +def _discovered_agent_from_wire(item: dict[str, Any]) -> DiscoveredAgent: + if not isinstance(item, dict): + raise A2AControlPlaneError( + code=502, + message="ListA2ASpaceAgents item must be an object", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="Agents", + ) + route_kind = str(item.get("RouteKind") or "") + if route_kind not in {"hosted_gateway", "external_public", "external_vpc"}: + raise A2AControlPlaneError( + code=502, + message="ListA2ASpaceAgents returned an unknown RouteKind", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="RouteKind", + ) + source = _required_str(item.get("Source"), field_name="Source") + if source not in {"hosted", "external"}: + raise A2AControlPlaneError( + code=502, + message="ListA2ASpaceAgents returned an unknown Source", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="Source", + ) + return DiscoveredAgent( + agent_id=_required_str( + item.get("A2AAgentId"), field_name="A2AAgentId", prefix="a2a-agent-" + ), + version_id=_required_str( + item.get("VersionId"), field_name="VersionId", prefix="a2a-version-" + ), + source=source, + agent_card=_agent_card_from_wire(item.get("AgentCard")), + card_sha256=str(item.get("CardSha256") or ""), + callable=bool(item.get("Callable", False)), + blocked_reason=_optional_str(item.get("BlockedReason")), + route_kind=route_kind, + ) + + +def _prepared_operation_from_wire(data: dict[str, Any]) -> PreparedA2AOperation: + raw_target = data.get("Target") + target: dict[str, Any] = raw_target if isinstance(raw_target, dict) else {} + raw_route = data.get("Route") + route: dict[str, Any] = raw_route if isinstance(raw_route, dict) else {} + raw_interface = route.get("Interface") + interface: dict[str, Any] = raw_interface if isinstance(raw_interface, dict) else {} + raw_remote_task = data.get("RemoteTask") + if raw_remote_task is not None and not isinstance(raw_remote_task, dict): + raise A2AControlPlaneError( + code=502, + message="control-plane response field RemoteTask must be an object or null", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="RemoteTask", + ) + remote_task: dict[str, Any] | None = ( + raw_remote_task if isinstance(raw_remote_task, dict) else None + ) + route_kind = _required_str(route.get("Kind"), field_name="Route.Kind") + if route_kind not in {"hosted_gateway", "external_public", "external_vpc"}: + raise A2AControlPlaneError( + code=502, + message="prepared operation returned an unknown route kind", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="Route.Kind", + ) + protocol_binding = _required_str( + interface.get("ProtocolBinding"), field_name="Route.Interface.ProtocolBinding" + ) + if protocol_binding not in {"JSONRPC", "HTTP+JSON"}: + raise A2AControlPlaneError( + code=502, + message="prepared operation returned an unsupported protocol binding", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="Route.Interface.ProtocolBinding", + ) + protocol_version = _required_str( + interface.get("ProtocolVersion"), field_name="Route.Interface.ProtocolVersion" + ) + if protocol_version != "1.0": + raise A2AControlPlaneError( + code=502, + message="prepared operation returned an unsupported protocol version", + error_code="A2A_CONTROL_PLANE_INVALID_RESPONSE", + field="Route.Interface.ProtocolVersion", + ) + return PreparedA2AOperation( + platform_task_id=_required_str( + data.get("A2ATaskId"), field_name="A2ATaskId", prefix="a2a-task-" + ), + target=A2ATarget( + agent_id=_required_str( + target.get("A2AAgentId"), + field_name="Target.A2AAgentId", + prefix="a2a-agent-", + ), + version_id=_required_str( + target.get("VersionId"), + field_name="Target.VersionId", + prefix="a2a-version-", + ), + card_sha256=_required_str(target.get("CardSha256"), field_name="Target.CardSha256"), + ), + route=A2ARoute( + kind=route_kind, + interface=A2ARouteInterface( + url=_required_str(interface.get("Url"), field_name="Route.Interface.Url"), + protocol_binding=protocol_binding, + protocol_version=protocol_version, + ), + ), + call_permit=_required_str(data.get("CallPermit"), field_name="CallPermit"), + call_permit_expires_at=_required_str( + data.get("CallPermitExpiresAt"), field_name="CallPermitExpiresAt" + ), + credential_handle=_optional_str(data.get("CredentialHandle")), + remote_task=( + RemoteTaskReference( + remote_task_id=_required_str( + remote_task.get("RemoteTaskId"), field_name="RemoteTask.RemoteTaskId" + ), + remote_context_id=_optional_str(remote_task.get("RemoteContextId")), + ) + if remote_task is not None + else None + ), + ) + + +__all__ = [ + "A2AInternalAction", + "A2A_INTERNAL_ACTIONS", + "A2A_INTERNAL_PATH_PREFIX", + "A2AControlPlane", + "A2AControlPlaneError", + "A2AOperation", + "A2ARoute", + "A2ARouteInterface", + "A2ATarget", + "CredentialInjection", + "DiscoveredAgent", + "ENV_A2A_CONTROL_PLANE_URL", + "ENV_A2A_TOKEN_DIR", + "FileWorkloadTokenProvider", + "InternalA2AControlPlaneClient", + "PreparedA2AOperation", + "RemoteTaskReference", + "SpaceAgentPage", + "WorkloadTokenProvider", + "build_a2a_internal_action_path", +] diff --git a/ksadk/a2a/credential.py b/ksadk/a2a/credential.py new file mode 100644 index 00000000..cc60c819 --- /dev/null +++ b/ksadk/a2a/credential.py @@ -0,0 +1,99 @@ +"""A2ACredentialProvider — 按 credential binding 获取短时出站凭据 (goal-06 §3.2)。 + +契约 §3.2:``A2ACredentialProvider`` 按 credential binding handle 解析出站调用凭据; +``credential_scheme_name``/``credential_secret_ref`` 表达 external 出站凭据 scheme 与 +Secret reference(可空)。7 月范围(§「7 月 credential adapter」):支持 **none / HTTP +bearer / HTTP basic / API key**;**OAuth2/OIDC 未支持**——注册可保存 schema,但调用返回 +明确 capability error,不静默成功也不伪造 token。 +""" + +from __future__ import annotations + +import base64 +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Optional + + +class CredentialCapabilityError(RuntimeError): + """credential scheme 超出当前能力(OAuth2/OIDC 未支持)时抛出。""" + + +@dataclass +class OutboundCredential: + """解析后的出站凭据(已物化为 HTTP 头;``scheme`` 记录来源 scheme)。""" + + scheme: str # "none" | "bearer" | "basic" | "apikey" + headers: dict[str, str] = field(default_factory=dict) + + +class A2ACredentialProvider(ABC): + """按 credential binding handle 解析出站凭据。""" + + @abstractmethod + async def resolve(self, credential_handle: Optional[str]) -> OutboundCredential: + """把 credential binding handle 解析为出站 HTTP 头。无 handle → ``none``。""" + raise NotImplementedError + + +#: 7 月支持的 scheme(其余注册可存,调用报 capability error)。 +_SUPPORTED_SCHEMES = frozenset({"none", "bearer", "basic", "apikey"}) +_UNSUPPORTED_OAUTH_SCHEMES = frozenset({"oauth2", "oidc", "oauth2_client_credentials"}) + + +class StaticCredentialProvider(A2ACredentialProvider): + """从注入的 ``handle -> binding`` 映射解析出站凭据(测试/本地;生产由 STS/Secret 后端替换)。 + + binding 结构:: + + {"scheme": "bearer", "token": "..."} + {"scheme": "basic", "username": "...", "password": "..."} + {"scheme": "apikey", "header": "X-API-Key", "key": "..."} + {"scheme": "none"} + {"scheme": "oauth2", ...} # 注册可存,resolve 抛 CredentialCapabilityError + """ + + def __init__(self, bindings: Optional[dict[str, dict[str, Any]]] = None) -> None: + self._bindings = dict(bindings or {}) + + def register(self, handle: str, binding: dict[str, Any]) -> None: + self._bindings[handle] = binding + + async def resolve(self, credential_handle: Optional[str]) -> OutboundCredential: + if not credential_handle: + return OutboundCredential(scheme="none", headers={}) + binding = self._bindings.get(credential_handle) + if binding is None: + raise KeyError(f"未知 credential binding handle: {credential_handle!r}") + scheme = str(binding.get("scheme") or "none").lower() + if scheme in _UNSUPPORTED_OAUTH_SCHEMES: + raise CredentialCapabilityError( + f"credential scheme {scheme!r} 当前未支持(7 月仅 none/bearer/basic/apikey);" + " 注册可保存 schema,调用返回明确 capability error" + ) + if scheme not in _SUPPORTED_SCHEMES: + raise CredentialCapabilityError(f"未知 credential scheme: {scheme!r}") + + if scheme == "bearer": + return OutboundCredential( + scheme="bearer", + headers={"Authorization": f"Bearer {binding.get('token', '')}"}, + ) + if scheme == "basic": + raw = f"{binding.get('username', '')}:{binding.get('password', '')}" + token = base64.b64encode(raw.encode()).decode() + return OutboundCredential(scheme="basic", headers={"Authorization": f"Basic {token}"}) + if scheme == "apikey": + header = str(binding.get("header") or "X-API-Key") + return OutboundCredential( + scheme="apikey", headers={header: str(binding.get("key", ""))} + ) + return OutboundCredential(scheme="none", headers={}) + + +__all__ = [ + "A2ACredentialProvider", + "CredentialCapabilityError", + "OutboundCredential", + "StaticCredentialProvider", +] diff --git a/ksadk/a2a/event_adapter.py b/ksadk/a2a/event_adapter.py new file mode 100644 index 00000000..5b4d7dd0 --- /dev/null +++ b/ksadk/a2a/event_adapter.py @@ -0,0 +1,149 @@ +"""A2AEventAdapter — A2A Message/Task/Artifact 与 RuntimeEvent 的双向转换 (goal-05)。 + +契约 §3.2:``A2AEventAdapter`` 负责 A2A wire 对象与 G0.2 RuntimeEvent 的双向转换; +§7.2:artifact/message ↔ RuntimeEvent artifact/text/data。 + +方向: +- ``task_status_to_event``:A2A TaskStatus/TaskState → RuntimeEvent(run.*)。 +- ``artifact_to_event``:A2A Artifact → RuntimeEvent(artifact.*)。 +- ``message_to_event``:A2A Message → RuntimeEvent(text.*)。 +- ``event_to_text_part``:RuntimeEvent(text.*)→ A2A ``Part``(用于出站)。 + +wire 对象是 protobuf(``a2a_pb2``);文本用 ``Part(text=...)``。 +""" + +from __future__ import annotations + +from typing import Any, Optional + +from a2a.types import Part, TaskState, TaskStatus + +from ksadk.events.runtime_event import EventType, RuntimeEvent + +#: A2A TaskState → RuntimeEvent run.* 事件类型映射。 +_TASK_STATE_TO_RUN_EVENT = { + TaskState.TASK_STATE_SUBMITTED: EventType.RUN_STARTED, + TaskState.TASK_STATE_WORKING: EventType.RUN_PROGRESS, + TaskState.TASK_STATE_COMPLETED: EventType.RUN_COMPLETED, + TaskState.TASK_STATE_FAILED: EventType.RUN_FAILED, + TaskState.TASK_STATE_CANCELED: EventType.RUN_CANCELED, + TaskState.TASK_STATE_INPUT_REQUIRED: EventType.RUN_INTERRUPTED, + TaskState.TASK_STATE_REJECTED: EventType.RUN_FAILED, +} + + +class A2AEventAdapter: + """A2A wire 对象 ↔ RuntimeEvent 双向转换器。""" + + # ---- A2A → RuntimeEvent ---- + + def task_status_to_event( + self, + status: TaskStatus, + *, + agent_id: str, + user_id: str, + session_id: str, + invocation_id: str, + seq_id: int, + event_id: Optional[str] = None, + ) -> RuntimeEvent: + """A2A TaskStatus → RuntimeEvent(run.*)。""" + event_type = _TASK_STATE_TO_RUN_EVENT.get(status.state, EventType.RUN_PROGRESS) + state_name = TaskState.Name(status.state) if status.state is not None else "unknown" + payload: dict[str, Any] = {"status": state_name} + if event_type == EventType.RUN_CANCELED: + payload["cancel_result"] = "interrupted_active_turn" + elif event_type == EventType.RUN_FAILED: + message = getattr(status, "message", None) + payload["error"] = self._parts_text(getattr(message, "parts", None)) or state_name + return RuntimeEvent.create( + event_type, + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + invocation_id=invocation_id, + seq_id=seq_id, + payload=payload, + event_id=event_id, + ) + + def artifact_to_event( + self, + artifact: Any, + *, + agent_id: str, + user_id: str, + session_id: str, + invocation_id: str, + seq_id: int, + event_id: Optional[str] = None, + ) -> RuntimeEvent: + """A2A Artifact → RuntimeEvent(artifact.*)。""" + name = getattr(artifact, "name", None) or "artifact" + text = self._parts_text(getattr(artifact, "parts", None)) + return RuntimeEvent.create( + EventType.ARTIFACT_CREATED, + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + invocation_id=invocation_id, + seq_id=seq_id, + payload={ + "artifact_id": str(getattr(artifact, "artifact_id", None) or ""), + "name": name, + "version": 1, + "text": text, + }, + event_id=event_id, + ) + + def message_to_event( + self, + text: str, + *, + final: bool, + agent_id: str, + user_id: str, + session_id: str, + invocation_id: str, + seq_id: int, + event_id: Optional[str] = None, + ) -> RuntimeEvent: + """A2A Message 文本 → RuntimeEvent(text.*,带相位)。""" + return RuntimeEvent.create( + EventType.TEXT_COMPLETED if final else EventType.TEXT_DELTA, + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + invocation_id=invocation_id, + seq_id=seq_id, + phase="final_answer" if final else "commentary", + payload={"text": text}, + event_id=event_id, + ) + + # ---- RuntimeEvent → A2A ---- + + @staticmethod + def event_to_text_part(event: RuntimeEvent) -> Optional[Part]: + """RuntimeEvent(text.*)→ A2A ``Part``(用于出站 message/artifact)。""" + event.validate_conformance() + if event.event_type not in (EventType.TEXT_DELTA, EventType.TEXT_COMPLETED): + return None + text = str(event.payload.get("text") or "") + return Part(text=text) if text else None + + @staticmethod + def _parts_text(parts: Any) -> str: + if not parts: + return "" + texts = [] + for part in parts: + value = getattr(part, "text", None) + if value: + texts.append(str(value)) + return "".join(texts) + + +__all__ = ["A2AEventAdapter"] diff --git a/ksadk/a2a/executor.py b/ksadk/a2a/executor.py index 64c020c7..0ea605d2 100644 --- a/ksadk/a2a/executor.py +++ b/ksadk/a2a/executor.py @@ -1,78 +1,288 @@ -"""A2A executor that adapts a KsADK runner to the A2A server contract.""" +"""A2ARuntimeExecutor — 把 ksadk runner 桥接进 A2A 请求生命周期 (goal-05)。 + +契约 §7.2:A2A ``context_id`` ↔ Runtime ``session_id``;``canceled`` ↔ +``RuntimeAdapter.cancel(invocation_id)``。executor 不在此自造 cancel,而是委托 +:class:`~ksadk.a2a.task_adapter.A2ARuntimeTaskAdapter`(其内部走 RuntimeAdapter.cancel, +G0.3 已冻结)。 + +wire 对象在 a2a-sdk 1.1.0 是 protobuf(``a2a_pb2``);文本用 ``Part(text=...)``, +消息用 ``Message(role=Role.ROLE_AGENT, parts=[...])``。 +""" from __future__ import annotations +import inspect import json import logging -from collections.abc import AsyncIterator -from typing import Any +from typing import Any, Literal from a2a.server.agent_execution import AgentExecutor, RequestContext from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater -from a2a.types import Part, TextPart +from a2a.types import Part, Task, TaskState, TaskStatus +from a2a.utils.errors import TaskNotCancelableError + +from ksadk.a2a.resume_store import A2AResumePayloadKind +from ksadk.events import EventType, RuntimeEvent +from ksadk.runtime import CancelResult, RunHandle logger = logging.getLogger(__name__) +#: ADK v2 A2A 集成扩展标记 URI(adk.dev/a2a/a2a-extension)。 +#: 在 Task/status metadata 里塞入该 key 且值非空时,ADK ``RemoteA2aAgent`` 走 +#: ``_handle_a2a_response_v2``——处理每个 ``artifact_update``(含 append=True 增量), +#: 不再像 v1 那样只放行首块+末块、丢弃中间增量(remote_a2a_agent.py:546/762)。 +#: ksadk 自研 executor 必须带这个标记,否则编排侧 sub-agent 流式被压成"一次性"。 +ADK_V2_INTEGRATION_EXTENSION_URI = "https://google.github.io/adk-docs/a2a/a2a-extension/" -class KsAgentExecutor(AgentExecutor): - """Execute a KsADK runner inside the A2A request lifecycle.""" +#: 扩展标记值(与 google.adk ``A2aAgentExecutor._get_invocation_metadata`` 对齐)。 +ADK_V2_INTEGRATION_METADATA: dict[str, Any] = { + ADK_V2_INTEGRATION_EXTENSION_URI: {"adk_agent_executor_v2": True} +} + +_OUTPUT_SNAPSHOT_METADATA = {"ksadk_output_snapshot": True} +_ArtifactKind = Literal["text", "thinking"] - def __init__(self, runner: Any, prefer_stream: bool = True) -> None: - self.runner = runner - self.prefer_stream = prefer_stream - async def execute( +def _thought_part(text: str) -> Part: + """构造可被 ADK RemoteA2aAgent 识别为 thought 的 A2A Part。""" + return Part(text=text, metadata={"adk_thought": True}) + + +class _ArtifactStreamEmitter: + """按连续类型分段输出 A2A artifact,并正确终止每个 artifact stream。""" + + def __init__(self, updater: TaskUpdater, task_id: str) -> None: + self._updater = updater + self._task_id = task_id + self._active_kind: _ArtifactKind | None = None + self._segments: dict[_ArtifactKind, int] = {"text": 0, "thinking": 0} + self._emitted: dict[tuple[_ArtifactKind, int], int] = {} + self._pending: tuple[_ArtifactKind, str, bool, int] | None = None + + async def push( self, - context: RequestContext, - event_queue: EventQueue, + kind: _ArtifactKind, + text: str, + *, + replace_snapshot: bool = False, + ) -> None: + if self._pending is not None: + switched = self._pending[0] != kind + await self._emit(self._pending, last_chunk=switched) + if self._active_kind != kind: + self._segments[kind] += 1 + self._active_kind = kind + self._pending = (kind, text, replace_snapshot, self._segments[kind]) + + async def close(self) -> None: + if self._pending is None: + return + await self._emit(self._pending, last_chunk=True) + self._pending = None + + async def _emit( + self, + pending: tuple[_ArtifactKind, str, bool, int], + *, + last_chunk: bool, + ) -> None: + kind, text, replace_snapshot, segment = pending + key = (kind, segment) + self._emitted[key] = self._emitted.get(key, 0) + 1 + is_reasoning = kind == "thinking" + base_id = ( + f"{self._task_id}-reasoning" + if is_reasoning + else f"{self._task_id}-response" + ) + artifact_id = base_id if segment == 1 else f"{base_id}-{segment}" + part = ( + _thought_part(text) + if is_reasoning + else Part( + text=text, + metadata=( + dict(_OUTPUT_SNAPSHOT_METADATA) + if replace_snapshot + else None + ), + ) + ) + await self._updater.add_artifact( + parts=[part], + artifact_id=artifact_id, + name="reasoning" if is_reasoning else "response", + append=False if replace_snapshot else self._emitted[key] > 1, + last_chunk=last_chunk, + ) + + +async def _enqueue_initial_task(context: RequestContext, event_queue: EventQueue) -> None: + """先入队初始 ``Task`` 对象,再发状态更新(a2a-sdk 1.1.0 生命周期要求: + TaskStatusUpdateEvent 之前必须已有 Task)。``current_task`` 为续跑任务时直接用, + 否则新建 submitted 任务。Task metadata 带 ADK v2 扩展标记,让 RemoteA2aAgent + 走 v2 handler 保留全部流式增量。 + """ + task = getattr(context, "current_task", None) + if task is None: + task = Task( + id=context.task_id, + context_id=context.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + metadata=dict(ADK_V2_INTEGRATION_METADATA), + ) + await event_queue.enqueue_event(task) + + +class _InputRequired(Exception): + """runner 请求用户输入的信号(内部用于跳出 execute,task 停 input-required)。""" + + +class _RunCanceled(Exception): + """Runtime 已取消本次执行,executor 不得再发 completed。""" + + +class A2ARuntimeExecutor(AgentExecutor): + """在 A2A 请求生命周期内执行 ksadk runner。 + + - sync: ``runner.invoke``。 + - streaming: ``runner.stream``,逐 chunk 发 artifact。 + - cancel: 委托 ``task_adapter.cancel_task``(内部走 RuntimeAdapter.cancel)。 + """ + + def __init__( + self, + runner: Any, + task_adapter: Any = None, + prefer_stream: bool = True, + include_reasoning: bool = False, ) -> None: + self.runner = runner + 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: + current_task = getattr(context, "current_task", None) + durable_context_id = str( + getattr(current_task, "context_id", "") or context.context_id or "unknown-context" + ) updater = TaskUpdater( event_queue=event_queue, task_id=context.task_id or "unknown-task", - context_id=context.context_id or "unknown-context", + context_id=durable_context_id, + ) + is_resume = ( + current_task is not None + and getattr(getattr(current_task, "status", None), "state", None) + == 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: + 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. + await self.task_adapter.validate_resume_task( + context.task_id or "", + context, + answer=interaction_response, + ) + handle: RunHandle | None = None try: - await updater.start_work() + # §7.2:当前任务处于 input-required 时,本条消息是续跑回包(checkpoint/resume)。 + if not is_resume: + await _enqueue_initial_task(context, event_queue) + # start_work 不透传 metadata;直接 update_status 以带 ADK v2 扩展标记 + # (RemoteA2aAgent 读 task/status metadata 决定走 v2 全增量 handler)。 + await updater.update_status( + TaskState.TASK_STATE_WORKING, + metadata=dict(ADK_V2_INTEGRATION_METADATA), + ) runner_input = self._build_runner_input(context) - output = await self._run_runner(context, updater, runner_input) + 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: + handle = await self.task_adapter.start_task( + task_id=str(context.task_id or ""), + context=context, + input_data=runner_input.get("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 分支对"增量累积"去重解决(不在此处删消息)。 completion_message = ( - updater.new_agent_message(parts=[Part(root=TextPart(text=output))]) - if output - else None + updater.new_agent_message(parts=[Part(text=output)]) if output else None ) await updater.complete(message=completion_message) - except Exception as exc: - logger.exception("A2A task execution failed") - error_text = self._coerce_text(exc) + await self._forget_task(context, handle) + except _InputRequired: + # runner 请求输入:task 已停在 input-required(附 resume token),不 complete。 + logger.info("A2A task %s 进入 input-required", context.task_id) + except _RunCanceled: + logger.info("A2A task %s 已由 runtime 取消", context.task_id) + await self._forget_task(context, handle) + except Exception as exc: # noqa: BLE001 + logger.error("A2A task execution failed (%s)", type(exc).__name__) await updater.failed( - message=updater.new_agent_message( - parts=[Part(root=TextPart(text=error_text))] - ) + message=updater.new_agent_message(parts=[Part(text="A2A task execution failed")]) ) + await self._forget_task(context, handle) - async def cancel( - self, - context: RequestContext, - event_queue: EventQueue, - ) -> None: + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + # §7.4:cancel 统一由 adapter 提供。有 RuntimeAdapter → 尊重其 CancelResult, + # 只有底层真取消(CANCELLED)才把协议 Task 置 canceled;其余状态如实抛 + # TaskNotCancelableError 拒绝,不伪造终态。无 adapter 时底层状态未知, + # 同样拒绝取消。 + current_task = getattr(context, "current_task", None) updater = TaskUpdater( event_queue=event_queue, task_id=context.task_id or "unknown-task", - context_id=context.context_id or "unknown-context", + context_id=str( + getattr(current_task, "context_id", "") or context.context_id or "unknown-context" + ), ) - await updater.cancel( - message=updater.new_agent_message( - parts=[Part(root=TextPart(text="Request canceled"))] + 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。 + if result not in ( + CancelResult.INTERRUPTED_ACTIVE_TURN, + CancelResult.PENDING_CANCEL_RECORDED, + ): + result_value = getattr(result, "value", result) + raise TaskNotCancelableError( + message=f"underlying runtime cancel did not apply: {result_value}" ) + await updater.cancel( + message=updater.new_agent_message(parts=[Part(text="Request canceled")]) ) + clear_resume_state = getattr(self.task_adapter, "clear_resume_state", None) + if callable(clear_resume_state): + result = clear_resume_state(context.task_id or "", context) + if inspect.isawaitable(result): + await result async def _run_runner( - self, - context: RequestContext, - updater: TaskUpdater, - runner_input: dict[str, Any], + self, context: RequestContext, updater: TaskUpdater, runner_input: dict[str, Any] ) -> str: stream = getattr(self.runner, "stream", None) if self.prefer_stream and callable(stream): @@ -82,7 +292,7 @@ async def _run_runner( text = self._coerce_text(result) if text: await updater.add_artifact( - parts=[Part(root=TextPart(text=text))], + parts=[Part(text=text)], artifact_id=f"{context.task_id}-response", name="response", last_chunk=True, @@ -97,85 +307,53 @@ async def _run_streaming( runner_input: dict[str, Any], ) -> str: output_text = "" - emitted_chunks = 0 - artifact_id = f"{context.task_id}-response" + artifacts = _ArtifactStreamEmitter(updater, str(context.task_id)) - async for chunk, last_chunk in self._with_last_flag(stream(runner_input)): + 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 "" + 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: - emitted_chunks += 1 - await updater.add_artifact( - parts=[Part(root=TextPart(text=suffix))], - artifact_id=artifact_id, - name="response", - append=bool(emitted_chunks > 1), - last_chunk=last_chunk, - ) + await artifacts.push("text", suffix) continue else: output_text = final_text - await updater.add_artifact( - parts=[Part(root=TextPart(text=final_text))], - artifact_id=artifact_id, - name="response", - append=False, - last_chunk=last_chunk, - ) + 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 output_text += text - emitted_chunks += 1 - await updater.add_artifact( - parts=[Part(root=TextPart(text=text))], - artifact_id=artifact_id, - name="response", - append=bool(emitted_chunks > 1), - last_chunk=last_chunk, - ) + await artifacts.push("text", text) + await artifacts.close() return output_text - async def _with_last_flag( - self, - iterator: AsyncIterator[Any], - ) -> AsyncIterator[tuple[Any, bool]]: - try: - previous = await anext(iterator) - except StopAsyncIteration: - return - - while True: - try: - current = await anext(iterator) - except StopAsyncIteration: - yield previous, True - return - yield previous, False - previous = current - - @staticmethod - def _build_runner_input(context: RequestContext) -> dict[str, Any]: - metadata = dict(context.metadata) + 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, @@ -186,6 +364,142 @@ def _build_runner_input(context: RequestContext) -> dict[str, Any]: "metadata": metadata, } + async def _run_runtime( + self, + context: RequestContext, + updater: TaskUpdater, + handle: RunHandle, + ) -> str: + output_text = "" + artifacts = _ArtifactStreamEmitter(updater, str(context.task_id)) + reasoning_text = "" + input_required = False + input_prompt = "Input required" + checkpoint_id: str | None = None + call_id: str | None = None + payload_kind: A2AResumePayloadKind = "hitl_answer" + + async for event in self.task_adapter.stream_task(handle): + if not isinstance(event, RuntimeEvent): + raise TypeError("RuntimeAdapter.stream must yield RuntimeEvent") + if event.event_type == EventType.RUN_FAILED: + raise RuntimeError(self._coerce_text(event.payload.get("error"))) + if event.event_type == EventType.RUN_CANCELED: + await artifacts.close() + if not self._cancel_was_accepted(context, handle): + await updater.cancel( + message=updater.new_agent_message(parts=[Part(text="Request canceled")]) + ) + raise _RunCanceled() + if event.event_type == EventType.APPROVAL_REQUESTED: + input_required = True + payload_kind = "approval_decision" + call_id = ( + str(event.payload.get("call_id") or event.payload.get("approval_id") or "") + or None + ) + detail = event.payload.get("detail") + if isinstance(detail, dict): + input_prompt = self._coerce_text( + detail.get("prompt") or detail.get("message") or input_prompt + ) + continue + if event.event_type == EventType.CHECKPOINT_CREATED: + checkpoint_id = str(event.payload.get("checkpoint_id") or "") or None + continue + if event.event_type == EventType.RUN_INTERRUPTED: + input_required = True + input_prompt = self._coerce_text( + event.payload.get("prompt") or event.payload.get("message") or input_prompt + ) + continue + if event.event_type not in { + EventType.TEXT_DELTA, + EventType.TEXT_COMPLETED, + EventType.REASONING_DELTA, + EventType.REASONING_COMPLETED, + }: + continue + text = self._coerce_text(event.payload.get("text")) + if not text: + continue + if event.event_type == EventType.REASONING_COMPLETED: + if not self.include_reasoning: + continue + if not reasoning_text: + delta = text + reasoning_text = text + elif text.startswith(reasoning_text): + delta = text[len(reasoning_text) :] + reasoning_text = text + else: + delta = text + reasoning_text += text + if delta: + await artifacts.push("thinking", delta) + continue + if event.event_type == EventType.REASONING_DELTA: + if not self.include_reasoning: + continue + reasoning_text += text + await artifacts.push("thinking", text) + continue + # TEXT_COMPLETED 是累计全文,去重只发新增 suffix;TEXT_DELTA 是增量直接透传。 + if event.event_type == EventType.TEXT_COMPLETED: + if not output_text: + delta = text + output_text = text + replace_snapshot = False + elif text.startswith(output_text): + delta = text[len(output_text) :] + output_text = text + replace_snapshot = False + else: + delta = text + output_text = text + replace_snapshot = True + else: + delta = text + output_text += text + replace_snapshot = False + if not delta: + continue + await artifacts.push("text", delta, replace_snapshot=replace_snapshot) + if self._cancel_was_accepted(context, handle): + await artifacts.close() + raise _RunCanceled() + await artifacts.close() + if input_required: + await self.task_adapter.persist_resume_state( + task_id=str(context.task_id or ""), + context=context, + handle=handle, + checkpoint_id=checkpoint_id, + call_id=call_id, + payload_kind=payload_kind, + ) + await updater.update_status( + TaskState.TASK_STATE_INPUT_REQUIRED, + message=updater.new_agent_message(parts=[Part(text=input_prompt)]), + metadata=dict(ADK_V2_INTEGRATION_METADATA), + ) + raise _InputRequired() + return output_text + + def _cancel_was_accepted(self, context: RequestContext, handle: RunHandle) -> bool: + was_cancel_accepted = getattr(self.task_adapter, "was_cancel_accepted", None) + return bool( + callable(was_cancel_accepted) + and was_cancel_accepted(str(context.task_id or ""), context, handle) + ) + + async def _forget_task(self, context: RequestContext, handle: RunHandle | None) -> None: + forget_task = getattr(self.task_adapter, "forget_task", None) + if handle is not None and callable(forget_task): + result = forget_task(str(context.task_id or ""), context, handle) + if inspect.isawaitable(result): + await result + @classmethod def _coerce_text(cls, payload: Any) -> str: if payload is None: @@ -199,3 +513,6 @@ def _coerce_text(cls, payload: Any) -> str: return cls._coerce_text(value) return json.dumps(payload, ensure_ascii=False, default=str) return str(payload) + + +__all__ = ["A2ARuntimeExecutor"] diff --git a/ksadk/a2a/external_transport.py b/ksadk/a2a/external_transport.py new file mode 100644 index 00000000..43c5c2db --- /dev/null +++ b/ksadk/a2a/external_transport.py @@ -0,0 +1,516 @@ +"""Lease-based external A2A transport boundary owned by the Runtime network guard.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import socket +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Sequence +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from dataclasses import dataclass +from typing import Any, AsyncIterator, Callable +from urllib.parse import urlsplit + +import httpcore +import httpx + +from ksadk.a2a.control_plane import A2ARouteInterface + + +@dataclass(frozen=True) +class A2ATransportLease: + """A single validated external route operation.""" + + httpx_client: httpx.AsyncClient + effective_interface: A2ARouteInterface + route_kind: str + policy_revision: str + + +class A2AExternalTransport(ABC): + """SSRF-safe external transport supplied by the Runtime network guard.""" + + @abstractmethod + def open_for_route( + self, + route: A2ARouteInterface, + *, + route_kind: str, + ) -> AbstractAsyncContextManager[A2ATransportLease]: + raise NotImplementedError + + +class A2ARouteOpener(ABC): + """Platform network guard operation that has completed DNS/VPC policy validation.""" + + @abstractmethod + def open_validated_route( + self, + route: A2ARouteInterface, + *, + route_kind: str, + ) -> AbstractAsyncContextManager[A2ATransportLease]: + raise NotImplementedError + + +class CallableA2ARouteOpener(A2ARouteOpener): + """Adapter for an injected Runtime network guard operation.""" + + def __init__( + self, + open_route: Callable[..., AbstractAsyncContextManager[A2ATransportLease]], + ) -> None: + self._open_route = open_route + + def open_validated_route( + self, + route: A2ARouteInterface, + *, + route_kind: str, + ) -> AbstractAsyncContextManager[A2ATransportLease]: + return self._open_route(route, route_kind=route_kind) + + +class GuardedA2AExternalTransport(A2AExternalTransport): + """Validates a Runtime network-guard lease before exposing its HTTP client.""" + + def __init__(self, route_opener: A2ARouteOpener) -> None: + self._route_opener = route_opener + + @asynccontextmanager + async def open_for_route( + self, + route: A2ARouteInterface, + *, + route_kind: str, + ) -> AsyncIterator[A2ATransportLease]: + if route_kind not in {"external_public", "external_vpc"}: + raise ValueError(f"external transport does not support route kind {route_kind!r}") + async with self._route_opener.open_validated_route( + route, + route_kind=route_kind, + ) as lease: + self._validate_lease(route, route_kind, lease) + yield lease + + @staticmethod + def _validate_lease( + requested: A2ARouteInterface, + route_kind: str, + lease: A2ATransportLease, + ) -> None: + if lease.route_kind != route_kind: + raise ValueError("network guard lease route kind does not match requested route") + if not lease.policy_revision.strip(): + raise ValueError("network guard lease is missing policy revision") + if _canonical_url(lease.effective_interface.url) != _canonical_url(requested.url): + raise ValueError("network guard lease route does not match requested route") + if lease.httpx_client.follow_redirects: + raise ValueError("external A2A transport must not follow redirects automatically") + if getattr(lease.httpx_client, "_trust_env", None) is not False: + raise ValueError("external A2A transport must disable environment proxies") + + +_RUNTIME_LOCAL_POLICY_REVISION = "runtime-local-dns-pinned-v1" +_EXTERNAL_CONNECT_TIMEOUT_SECONDS = 5.0 +_EXTERNAL_READ_TIMEOUT_SECONDS = 30.0 +_EXTERNAL_OPERATION_TIMEOUT_SECONDS = 300.0 +_MAX_EXTERNAL_RESPONSE_BYTES = 8 * 1024 * 1024 +_MAX_EXTERNAL_JSON_DEPTH = 64 +_MAX_EXTERNAL_JSON_STRING_BYTES = 1024 * 1024 +ERR_VPC_EGRESS_DIALER_REQUIRED = "A2A_VPC_EGRESS_DIALER_REQUIRED" + + +async def _resolve_hostname(host: str, port: int) -> Sequence[str]: + """Resolve one operation hostname without retaining an SDK-level DNS cache.""" + + loop = asyncio.get_running_loop() + results = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM) + addresses = tuple(dict.fromkeys(str(item[4][0]) for item in results)) + if not addresses: + raise OSError(f"DNS lookup returned no addresses for {host!r}") + return addresses + + +class _PinnedDNSNetworkBackend(httpcore.AsyncNetworkBackend): + """Dials a verified IP while httpcore retains the hostname for TLS/SNI.""" + + def __init__( + self, + delegate: httpcore.AsyncNetworkBackend, + *, + expected_hostname: str, + expected_port: int, + pinned_ip: str, + ) -> None: + self._delegate = delegate + self._expected_hostname = expected_hostname + self._expected_port = expected_port + self._pinned_ip = pinned_ip + + async def connect_tcp( + self, + host: str, + port: int, + timeout: float | None = None, + local_address: str | None = None, + socket_options: Any = None, + ) -> httpcore.AsyncNetworkStream: + if host.lower() != self._expected_hostname or port != self._expected_port: + raise RuntimeError("external A2A transport attempted to dial an unapproved origin") + return await self._delegate.connect_tcp( + self._pinned_ip, + port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + + async def connect_unix_socket( + self, + path: str, + timeout: float | None = None, + socket_options: Any = None, + ) -> httpcore.AsyncNetworkStream: + return await self._delegate.connect_unix_socket( + path, + timeout=timeout, + socket_options=socket_options, + ) + + async def sleep(self, seconds: float) -> None: + await self._delegate.sleep(seconds) + + +class _BoundedResponseStream(httpx.AsyncByteStream): + """Enforces one operation deadline and a raw response-body limit.""" + + def __init__( + self, + delegate: httpx.AsyncByteStream, + *, + max_response_bytes: int, + deadline: float, + ) -> None: + self._delegate = delegate + self._max_response_bytes = max_response_bytes + self._deadline = deadline + self._received_bytes = 0 + self._json_limits = _JSONPayloadLimits() + + async def __aiter__(self) -> AsyncIterator[bytes]: + iterator = self._delegate.__aiter__() + while True: + remaining = self._deadline - asyncio.get_running_loop().time() + if remaining <= 0: + await self.aclose() + raise httpx.ReadTimeout( + "external A2A operation exceeded its total response deadline" + ) + try: + chunk = await asyncio.wait_for(anext(iterator), timeout=remaining) + except StopAsyncIteration: + return + except TimeoutError as exc: + await self.aclose() + raise httpx.ReadTimeout( + "external A2A operation exceeded its total response deadline" + ) from exc + self._received_bytes += len(chunk) + if self._received_bytes > self._max_response_bytes: + await self.aclose() + raise httpx.RemoteProtocolError( + "external A2A response exceeds configured size limit" + ) + self._json_limits.observe(chunk) + yield chunk + + async def aclose(self) -> None: + await self._delegate.aclose() + + +class _JSONPayloadLimits: + """Lightweight structural guard before the A2A SDK parses external JSON/SSE.""" + + def __init__(self) -> None: + self._depth = 0 + self._in_string = False + self._escaped = False + self._string_bytes = 0 + + def observe(self, chunk: bytes) -> None: + for byte in chunk: + if self._in_string: + self._string_bytes += 1 + if self._string_bytes > _MAX_EXTERNAL_JSON_STRING_BYTES: + raise httpx.RemoteProtocolError( + "external A2A JSON string exceeds 1 MiB" + ) + if self._escaped: + self._escaped = False + elif byte == ord("\\"): + self._escaped = True + elif byte == ord('"'): + self._in_string = False + self._string_bytes = 0 + continue + if byte == ord('"'): + self._in_string = True + self._string_bytes = 0 + elif byte in (ord("{"), ord("[")): + self._depth += 1 + if self._depth > _MAX_EXTERNAL_JSON_DEPTH: + raise httpx.RemoteProtocolError("external A2A JSON exceeds depth 64") + elif byte in (ord("}"), ord("]")): + self._depth = max(0, self._depth - 1) + + +class _OriginLockedRedirectRejectingTransport(httpx.AsyncBaseTransport): + """Restricts a lease client to its resolved origin and rejects all redirects.""" + + def __init__( + self, + delegate: httpx.AsyncBaseTransport, + *, + hostname: str, + port: int, + max_response_bytes: int, + operation_timeout_seconds: float, + ) -> None: + self._delegate = delegate + self._hostname = hostname + self._port = port + self._max_response_bytes = max_response_bytes + self._operation_timeout_seconds = operation_timeout_seconds + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + request_port = request.url.port or (443 if request.url.scheme == "https" else 80) + if ( + request.url.scheme != "https" + or request.url.host.lower() != self._hostname + or request_port != self._port + ): + raise RuntimeError("external A2A transport lease attempted to use an unapproved origin") + request.headers["Accept-Encoding"] = "identity" + deadline = asyncio.get_running_loop().time() + self._operation_timeout_seconds + try: + response = await asyncio.wait_for( + self._delegate.handle_async_request(request), + timeout=self._operation_timeout_seconds, + ) + except TimeoutError as exc: + raise httpx.ReadTimeout( + "external A2A operation exceeded its total response deadline" + ) from exc + if 300 <= response.status_code < 400: + await response.aclose() + raise httpx.RemoteProtocolError("A2A external redirect responses are not allowed") + declared_length = response.headers.get("content-length") + if declared_length is not None: + try: + exceeds_limit = int(declared_length) > self._max_response_bytes + except ValueError as exc: + await response.aclose() + raise httpx.RemoteProtocolError( + "external A2A response has an invalid Content-Length" + ) from exc + if exceeds_limit: + await response.aclose() + raise httpx.RemoteProtocolError( + "external A2A response exceeds configured size limit" + ) + if not isinstance(response.stream, httpx.AsyncByteStream): + await response.aclose() + raise RuntimeError("external A2A transport received a non-async response stream") + response.stream = _BoundedResponseStream( + response.stream, + max_response_bytes=self._max_response_bytes, + deadline=deadline, + ) + return response + + async def aclose(self) -> None: + await self._delegate.aclose() + + +class RuntimeLocalA2AExternalTransport(A2AExternalTransport): + """Runtime-local guarded HTTPS transport for public external Agents over NAT. + + The Runtime resolves every external operation itself, rejects any non-global + DNS result, then pins the selected IP in the actual TCP backend. The HTTP + origin remains the original hostname so certificate validation, ``Host``, + and TLS SNI are not weakened by the pinning. + """ + + def __init__( + self, + *, + resolve_hostname: Callable[[str, int], Awaitable[Sequence[str]]] = _resolve_hostname, + network_backend: httpcore.AsyncNetworkBackend | None = None, + connect_timeout_seconds: float = _EXTERNAL_CONNECT_TIMEOUT_SECONDS, + read_timeout_seconds: float = _EXTERNAL_READ_TIMEOUT_SECONDS, + operation_timeout_seconds: float = _EXTERNAL_OPERATION_TIMEOUT_SECONDS, + max_response_bytes: int = _MAX_EXTERNAL_RESPONSE_BYTES, + ) -> None: + if ( + connect_timeout_seconds <= 0 + or read_timeout_seconds <= 0 + or operation_timeout_seconds <= 0 + or max_response_bytes <= 0 + ): + raise ValueError("external A2A transport limits must be positive") + self._resolve_hostname = resolve_hostname + self._network_backend = network_backend or httpcore.AnyIOBackend() + self._connect_timeout_seconds = connect_timeout_seconds + self._read_timeout_seconds = read_timeout_seconds + self._operation_timeout_seconds = operation_timeout_seconds + self._max_response_bytes = max_response_bytes + + @asynccontextmanager + async def open_for_route( + self, + route: A2ARouteInterface, + *, + route_kind: str, + ) -> AsyncIterator[A2ATransportLease]: + hostname, port = self._validated_public_origin(route, route_kind=route_kind) + try: + resolved = await asyncio.wait_for( + self._resolve_hostname(hostname, port), + timeout=self._connect_timeout_seconds, + ) + except TimeoutError as exc: + raise httpx.ConnectTimeout("external A2A DNS resolution timed out") from exc + pinned_ip = self._select_global_ip(resolved, hostname=hostname) + client = self._build_client(hostname=hostname, port=port, pinned_ip=pinned_ip) + try: + yield A2ATransportLease( + httpx_client=client, + effective_interface=route, + route_kind=route_kind, + policy_revision=_RUNTIME_LOCAL_POLICY_REVISION, + ) + finally: + await client.aclose() + + @staticmethod + def _validated_public_origin( + route: A2ARouteInterface, + *, + route_kind: str, + ) -> tuple[str, int]: + if route_kind == "external_vpc": + raise RuntimeError( + f"{ERR_VPC_EGRESS_DIALER_REQUIRED}: external_vpc routes require a " + "platform-injected VPC dialer" + ) + if route_kind != "external_public": + raise ValueError( + "RuntimeLocalA2AExternalTransport supports only external_public routes" + ) + parsed = urlsplit(route.url) + try: + port = parsed.port + except ValueError as exc: + raise ValueError("external public A2A route has an invalid port") from exc + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or parsed.netloc.rsplit("@", 1)[-1].endswith(":") + or port is not None and not 1 <= port <= 65535 + ): + raise ValueError("external public A2A route must be an absolute HTTPS URL") + try: + hostname = parsed.hostname.encode("idna").decode("ascii").lower() + except UnicodeError as exc: + raise ValueError("external public A2A route has an invalid hostname") from exc + return hostname, port or 443 + + @staticmethod + def _select_global_ip(addresses: Sequence[str], *, hostname: str) -> str: + if not addresses: + raise PermissionError(f"external public A2A DNS returned no addresses for {hostname!r}") + normalized: list[str] = [] + for value in addresses: + try: + address = ipaddress.ip_address(value) + except ValueError as exc: + raise PermissionError( + f"external public A2A DNS returned an invalid address for {hostname!r}" + ) from exc + if not address.is_global: + raise PermissionError( + "external public A2A DNS results must contain only globally routable IPs" + ) + normalized.append(str(address)) + return normalized[0] + + def _build_client(self, *, hostname: str, port: int, pinned_ip: str) -> httpx.AsyncClient: + # httpx has no public network-backend hook. Rebuild its direct httpcore pool + # to preserve httpx's SSL configuration while replacing only TCP dialing. + base_transport = httpx.AsyncHTTPTransport( + trust_env=False, + http1=True, + http2=False, + limits=httpx.Limits(max_connections=1, max_keepalive_connections=0), + ) + existing_pool = getattr(base_transport, "_pool", None) + if not isinstance(existing_pool, httpcore.AsyncConnectionPool): + raise RuntimeError("httpx direct async transport is unavailable for A2A DNS pinning") + base_transport._pool = httpcore.AsyncConnectionPool( # type: ignore[attr-defined] + ssl_context=existing_pool._ssl_context, + max_connections=1, + max_keepalive_connections=0, + keepalive_expiry=existing_pool._keepalive_expiry, + http1=True, + http2=False, + retries=0, + network_backend=_PinnedDNSNetworkBackend( + self._network_backend, + expected_hostname=hostname, + expected_port=port, + pinned_ip=pinned_ip, + ), + ) + transport = _OriginLockedRedirectRejectingTransport( + base_transport, + hostname=hostname, + port=port, + max_response_bytes=self._max_response_bytes, + operation_timeout_seconds=self._operation_timeout_seconds, + ) + return httpx.AsyncClient( + transport=transport, + follow_redirects=False, + trust_env=False, + timeout=httpx.Timeout( + connect=self._connect_timeout_seconds, + read=self._read_timeout_seconds, + write=self._read_timeout_seconds, + pool=self._connect_timeout_seconds, + ), + ) + + +def _canonical_url(value: str) -> tuple[str, str, int | None, str, str]: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("external A2A route must use an absolute HTTP(S) URL") + default_port = 443 if parsed.scheme == "https" else 80 + port = parsed.port or default_port + return parsed.scheme, parsed.hostname.lower(), port, parsed.path or "/", parsed.query + + +__all__ = [ + "A2AExternalTransport", + "A2ARouteOpener", + "A2ATransportLease", + "CallableA2ARouteOpener", + "ERR_VPC_EGRESS_DIALER_REQUIRED", + "GuardedA2AExternalTransport", + "RuntimeLocalA2AExternalTransport", +] diff --git a/ksadk/a2a/identity.py b/ksadk/a2a/identity.py new file mode 100644 index 00000000..72c4a51c --- /dev/null +++ b/ksadk/a2a/identity.py @@ -0,0 +1,304 @@ +"""Trusted Gateway identity boundary for inbound A2A protocol requests.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from ksadk.a2a.context_store import A2AContextIdentity +from ksadk.a2a.ids import require_a2a_resource_id + +_CALLER_PRINCIPAL_TYPES = frozenset({"user", "runtime", "service"}) +_AUTHN_MODE_BY_CALLER_TYPE = { + "user": "api_key", + "runtime": "workload_permit", + "service": "gateway_service", +} + + +@dataclass(frozen=True) +class A2AIngressTargetBinding: + """The Gateway-verified destination to which one Runtime is bound.""" + + account_id: str + tenant_id: str + agent_id: str + runtime_id: str + a2a_agent_id: str + + +@dataclass(frozen=True) +class A2AIngressIdentity: + """Caller identity verified by Gateway mTLS and target-bound forwarding.""" + + account_id: str + tenant_id: str + caller_principal_type: str + caller_principal_id: str + target_agent_id: str + target_runtime_id: str + target_a2a_agent_id: str + authn_mode: str + caller_runtime_id: str | None = None + + def validate(self) -> None: + values = { + "account_id": self.account_id, + "caller_principal_type": self.caller_principal_type, + "caller_principal_id": self.caller_principal_id, + "target_agent_id": self.target_agent_id, + "target_runtime_id": self.target_runtime_id, + "target_a2a_agent_id": self.target_a2a_agent_id, + "authn_mode": self.authn_mode, + } + missing = [name for name, value in values.items() if not str(value).strip()] + if missing: + raise PermissionError( + "verified Gateway identity is missing " + ", ".join(missing) + ) + if self.caller_principal_type not in _CALLER_PRINCIPAL_TYPES: + raise PermissionError("verified Gateway identity has an unsupported caller type") + if self.authn_mode != _AUTHN_MODE_BY_CALLER_TYPE[self.caller_principal_type]: + raise PermissionError("verified Gateway identity has an invalid authentication mode") + if self.caller_principal_type == "runtime" and not str( + self.caller_runtime_id or "" + ).strip(): + raise PermissionError("verified runtime caller identity is missing caller runtime") + if self.caller_principal_type != "runtime" and self.caller_runtime_id is not None: + raise PermissionError("verified non-runtime caller identity includes a caller runtime") + for field_name, limit in ( + ("account_id", 64), + ("tenant_id", 64), + ("caller_principal_type", 16), + ("caller_principal_id", 128), + ("target_agent_id", 64), + ("target_runtime_id", 64), + ("target_a2a_agent_id", 64), + ("authn_mode", 32), + ("caller_runtime_id", 64), + ): + value = getattr(self, field_name) + if value is not None and len(str(value)) > limit: + raise PermissionError( + f"verified Gateway identity field {field_name} exceeds {limit} characters" + ) + if not self.target_agent_id.startswith("ar-"): + raise PermissionError("verified Gateway identity has an invalid target Agent") + try: + require_a2a_resource_id( + self.target_a2a_agent_id, + "a2a-agent-", + field_name="verified Gateway identity target A2A Agent", + ) + except ValueError as exc: + raise PermissionError( + "verified Gateway identity has an invalid target A2A Agent" + ) from exc + + def context_identity(self) -> A2AContextIdentity: + return A2AContextIdentity( + account_id=self.account_id, + tenant_id=self.tenant_id, + caller_principal_type=self.caller_principal_type, + caller_principal_id=self.caller_principal_id, + ) + + def owner_key(self) -> str: + return "/".join( + ( + self.account_id.strip(), + self.tenant_id.strip(), + self.caller_principal_type.strip(), + self.caller_principal_id.strip(), + ) + ) + + +class GatewayIdentityVerifier(ABC): + """Platform adapter that verifies Gateway mTLS and target-bound forwarding.""" + + @abstractmethod + async def verify(self, request: Request) -> A2AIngressIdentity: + raise NotImplementedError + + +class GatewayProbeVerifier(ABC): + """Verifies a trusted Gateway/server probe for the Runtime-local AgentCard.""" + + @abstractmethod + async def verify_probe(self, request: Request) -> None: + raise NotImplementedError + + +class CallableGatewayIdentityVerifier(GatewayIdentityVerifier): + """Small adapter for product injectors and tests.""" + + def __init__( + self, + verify: Callable[[Request], Awaitable[A2AIngressIdentity]], + ) -> None: + self._verify = verify + + async def verify(self, request: Request) -> A2AIngressIdentity: + return await self._verify(request) + + +class CallableGatewayProbeVerifier(GatewayProbeVerifier): + """Small probe-verifier adapter for product injectors and tests.""" + + def __init__(self, verify_probe: Callable[[Request], Awaitable[None]]) -> None: + self._verify_probe = verify_probe + + async def verify_probe(self, request: Request) -> None: + await self._verify_probe(request) + + +class A2ATrustedIdentityResolver: + """Reads only identity written by verified ingress middleware.""" + + state_key = "a2a_identity" + + def __init__( + self, + *, + expected_account_id: str | None = None, + expected_tenant_id: str | None = None, + expected_target_agent_id: str | None = None, + expected_target_runtime_id: str | None = None, + expected_target_a2a_agent_id: str | None = None, + target_binding: A2AIngressTargetBinding | None = None, + ) -> None: + if target_binding is not None: + if any( + value is not None + for value in ( + expected_account_id, + expected_tenant_id, + expected_target_agent_id, + expected_target_runtime_id, + expected_target_a2a_agent_id, + ) + ): + raise ValueError( + "pass target_binding or individual expected target fields, not both" + ) + expected_account_id = target_binding.account_id + expected_tenant_id = target_binding.tenant_id + expected_target_agent_id = target_binding.agent_id + expected_target_runtime_id = target_binding.runtime_id + expected_target_a2a_agent_id = target_binding.a2a_agent_id + self._expected_account_id = str(expected_account_id or "").strip() or None + self._expected_tenant_id = expected_tenant_id + self._expected_target_agent_id = str(expected_target_agent_id or "").strip() or None + self._expected_target_runtime_id = str(expected_target_runtime_id or "").strip() or None + self._expected_target_a2a_agent_id = str(expected_target_a2a_agent_id or "").strip() or None + + def resolve(self, request: Request) -> A2AIngressIdentity: + raw_state = request.scope.get("state") + state = raw_state if isinstance(raw_state, dict) else {} + identity = state.get(self.state_key) + if not isinstance(identity, A2AIngressIdentity): + raise PermissionError("verified Gateway identity is required for inbound A2A") + self._validate(identity) + return identity + + def _validate(self, identity: A2AIngressIdentity) -> None: + identity.validate() + identity.context_identity().canonical_owner() + if ( + self._expected_account_id is not None + and identity.account_id != self._expected_account_id + ): + raise PermissionError("verified Gateway identity targets a different account") + if ( + self._expected_tenant_id is not None + and identity.tenant_id != self._expected_tenant_id + ): + raise PermissionError("verified Gateway identity targets a different tenant") + if ( + self._expected_target_agent_id is not None + and identity.target_agent_id != self._expected_target_agent_id + ): + raise PermissionError("verified Gateway identity targets a different Agent") + if ( + self._expected_target_runtime_id is not None + and identity.target_runtime_id != self._expected_target_runtime_id + ): + raise PermissionError("verified Gateway identity targets a different Runtime") + if ( + self._expected_target_a2a_agent_id is not None + and identity.target_a2a_agent_id != self._expected_target_a2a_agent_id + ): + raise PermissionError("verified Gateway identity targets a different A2A Agent") + + +class A2AGatewayIdentityMiddleware(BaseHTTPMiddleware): + """Verify Gateway identity before JSON-RPC or HTTP+JSON A2A handlers run.""" + + def __init__( + self, + app: Any, + *, + verifier: GatewayIdentityVerifier, + probe_verifier: GatewayProbeVerifier | None = None, + expected_account_id: str | None = None, + expected_tenant_id: str | None = None, + expected_target_agent_id: str | None = None, + expected_target_runtime_id: str | None = None, + expected_target_a2a_agent_id: str | None = None, + target_binding: A2AIngressTargetBinding | None = None, + ) -> None: + super().__init__(app) + self._verifier = verifier + self._probe_verifier = probe_verifier + self._identity_resolver = A2ATrustedIdentityResolver( + expected_account_id=expected_account_id, + expected_tenant_id=expected_tenant_id, + expected_target_agent_id=expected_target_agent_id, + expected_target_runtime_id=expected_target_runtime_id, + expected_target_a2a_agent_id=expected_target_a2a_agent_id, + target_binding=target_binding, + ) + + async def dispatch(self, request: Request, call_next: Any): + if request.url.path == "/.well-known/agent-card.json": + try: + if self._probe_verifier is None: + raise PermissionError("A2A AgentCard probe verifier is not configured") + await self._probe_verifier.verify_probe(request) + except Exception: + return JSONResponse( + status_code=401, + content={"detail": "trusted Gateway identity is required for A2A AgentCard"}, + ) + elif request.url.path == "/a2a/jsonrpc" or request.url.path.startswith("/a2a/v1/"): + try: + identity = await self._verifier.verify(request) + self._identity_resolver._validate(identity) + except Exception: + return JSONResponse( + status_code=401, + content={"detail": "verified Gateway identity is required for inbound A2A"}, + ) + raw_state = request.scope.get("state") + state = raw_state if isinstance(raw_state, dict) else {} + state[A2ATrustedIdentityResolver.state_key] = identity + request.scope["state"] = state + return await call_next(request) + + +__all__ = [ + "A2AGatewayIdentityMiddleware", + "A2AIngressTargetBinding", + "A2AIngressIdentity", + "A2ATrustedIdentityResolver", + "CallableGatewayIdentityVerifier", + "CallableGatewayProbeVerifier", + "GatewayIdentityVerifier", + "GatewayProbeVerifier", +] diff --git a/ksadk/a2a/ids.py b/ksadk/a2a/ids.py new file mode 100644 index 00000000..1261e7d9 --- /dev/null +++ b/ksadk/a2a/ids.py @@ -0,0 +1,22 @@ +"""AgentEngine A2A resource ID validation.""" + +from __future__ import annotations + +import re + +_UUID4_HEX = re.compile(r"^[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}$") + + +def is_a2a_resource_id(value: str, prefix: str) -> bool: + """Return whether value is `` with a lowercase compact UUID.""" + return value.startswith(prefix) and bool(_UUID4_HEX.fullmatch(value.removeprefix(prefix))) + + +def require_a2a_resource_id(value: str, prefix: str, *, field_name: str) -> str: + """Validate an AgentEngine A2A resource ID and return it unchanged.""" + if not is_a2a_resource_id(value, prefix): + raise ValueError(f"{field_name} must use the {prefix} format") + return value + + +__all__ = ["is_a2a_resource_id", "require_a2a_resource_id"] diff --git a/ksadk/a2a/langgraph.py b/ksadk/a2a/langgraph.py new file mode 100644 index 00000000..1b27ead0 --- /dev/null +++ b/ksadk/a2a/langgraph.py @@ -0,0 +1,285 @@ +"""LangGraph 编排方调远端 A2A agent 的流式 helper。 + +ADK 有原生 ``RemoteA2aAgent``;LangGraph 没有等价封装。这里提供结构化流式 +helper,保留远端 ``thinking`` / ``text`` 类型,并可直接写入 LangGraph custom +stream,无需 task store、无需 Space 绑定——直接走 a2a-sdk 原生 client。 +""" + +from __future__ import annotations + +import inspect +import uuid +from collections.abc import AsyncIterator +from typing import Any, Callable, Literal, TypedDict + +import httpx +from a2a.client import ClientCallContext, ClientConfig, create_client +from a2a.client.card_resolver import A2ACardResolver +from a2a.types import Message, Part, Role, SendMessageRequest + + +class A2AStreamEvent(TypedDict): + """可直接交给 LangGraph custom writer 的远端流式事件。""" + + type: Literal["thinking", "text"] + delta: str + replace: bool + + +def _base_url(agent_card_url: str) -> str: + """从 agent_card_url 取 base_url(剥掉 well-known 路径后缀)。""" + url = agent_card_url.strip().rstrip("/") + for suffix in ("/.well-known/agent-card.json", "/.well-known/agent.json"): + if url.endswith(suffix): + url = url[: -len(suffix)] + return url + + +async def stream_a2a_agent_events( + agent_card_url: str, + message: str, + *, + httpx_client: httpx.AsyncClient | None = None, + timeout: float = 60.0, +) -> AsyncIterator[A2AStreamEvent]: + """流式调用远端 A2A agent,保留 reasoning 与正文的结构化类型。 + + 在 langgraph node 里配合 custom stream 透传:: + + from langgraph.config import get_stream_writer + from ksadk.a2a.langgraph import stream_a2a_agent_events + + async def call_demo(state, config): + writer = get_stream_writer() + collected = "" + async for event in stream_a2a_agent_events( + "http://127.0.0.1:8094", state["query"] + ): + writer(event) + if event["type"] == "text": + collected += event["delta"] + return {"result": collected} + + Args: + agent_card_url: 远端 agent 的 base URL(如 ``http://127.0.0.1:8094``) + 或完整 agent-card.json URL。 + message: 发给远端 agent 的用户消息。 + httpx_client: 可选复用的 ``httpx.AsyncClient``;不传则内部创建。 + timeout: httpx 请求超时秒数。 + + Yields: + ``{"type": "thinking"|"text", "delta": str, "replace": bool}``。 + reasoning 由 ``adk_thought`` Part metadata 识别,status_update 不伪装 + 成思考;``replace`` 标识权威快照替换既有同类输出。 + """ + own_client = httpx_client is None + client_httpx = httpx_client or httpx.AsyncClient(timeout=timeout) + try: + resolver = A2ACardResolver(httpx_client=client_httpx, base_url=_base_url(agent_card_url)) + card = await resolver.get_agent_card() + client = await create_client( + agent=card, + client_config=ClientConfig(httpx_client=client_httpx, streaming=True), + ) + request = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + parts=[Part(text=message)], + message_id=f"lg-a2a-{uuid.uuid4().hex}", + ) + ) + snapshots: dict[tuple[str, str], str] = {} + async for event in client.send_message(request, context=ClientCallContext()): + for stream_event in _extract_artifact_events(event, snapshots): + yield stream_event + finally: + if own_client: + await client_httpx.aclose() + + +async def stream_a2a_agent( + agent_card_url: str, + message: str, + *, + httpx_client: httpx.AsyncClient | None = None, + timeout: float = 60.0, +) -> AsyncIterator[str]: + """兼容旧接口:只产出正文文本增量,reasoning 不混入最终答案。 + + 该字符串流无法表达 ``replace`` 语义。需要正确处理远端权威快照的调用方 + 应使用 ``stream_a2a_agent_events`` 或 ``stream_a2a_agent_to_writer``。 + """ + async for event in stream_a2a_agent_events( + agent_card_url, + message, + httpx_client=httpx_client, + timeout=timeout, + ): + if event["type"] == "text": + yield event["delta"] + + +async def stream_a2a_agent_to_writer( + agent_card_url: str, + message: str, + *, + writer: Callable[[A2AStreamEvent], Any] | None = None, + httpx_client: httpx.AsyncClient | None = None, + timeout: float = 60.0, +) -> str: + """将远端 typed events 写入 LangGraph custom stream,返回纯正文结果。 + + ``writer`` 不传时自动使用当前 LangGraph node 的 ``get_stream_writer()``。 + demo 只需调用本函数,无需了解 A2A wire artifact、append 或快照去重。 + """ + if writer is None: + from langgraph.config import get_stream_writer + + writer = get_stream_writer() + + output: list[str] = [] + async for event in stream_a2a_agent_events( + agent_card_url, + message, + httpx_client=httpx_client, + timeout=timeout, + ): + write_result = writer(event) + if inspect.isawaitable(write_result): + await write_result + if event["type"] == "text": + if event["replace"]: + output = [event["delta"]] + else: + output.append(event["delta"]) + return "".join(output) + + +def _extract_artifact_events( + event: Any, + snapshots: dict[tuple[str, str], str], +) -> list[A2AStreamEvent]: + """从 A2A artifact_update 提取去重后的 typed events。 + + 快照按 ``(artifact_id, type)`` 隔离,避免 reasoning 与 response 互相覆盖。 + ``append=False`` 按累计快照 diff,``append=True`` 按增量直接透传。 + """ + which = None + try: + which = event.WhichOneof("payload") + except Exception: # 非 protobuf 或结构不符时静默跳过 + which = None + if which == "artifact_update": + artifact_update = getattr(event, "artifact_update", None) + artifact = getattr(artifact_update, "artifact", None) + if artifact is None: + return [] + return _events_from_artifact( + artifact, + append=bool(getattr(artifact_update, "append", False)), + snapshots=snapshots, + ) + if which == "task": + result: list[A2AStreamEvent] = [] + task = getattr(event, "task", None) + for artifact in getattr(task, "artifacts", []): + result.extend(_events_from_artifact(artifact, append=False, snapshots=snapshots)) + return result + if which == "message": + message = getattr(event, "message", None) + if message is None: + return [] + artifact = _MessageArtifact(message) + return _events_from_artifact(artifact, append=False, snapshots=snapshots) + return [] + + +class _MessageArtifact: + """把直接 Message 响应投影成 artifact-like 对象复用解析逻辑。""" + + def __init__(self, message: Any) -> None: + self.artifact_id = f"message:{getattr(message, 'message_id', '') or 'anonymous'}" + self.name = "response" + self.parts = getattr(message, "parts", []) + self.metadata = getattr(message, "metadata", None) + + +def _events_from_artifact( + artifact: Any, + *, + append: bool, + snapshots: dict[tuple[str, str], str], +) -> list[A2AStreamEvent]: + """解析一个 artifact-like 对象,支持增量、累计快照和权威替换。""" + + artifact_id = str( + getattr(artifact, "artifact_id", "") + or getattr(artifact, "name", "") + or "anonymous" + ) + artifact_is_reasoning = ( + str(getattr(artifact, "name", "")).lower() == "reasoning" + or _metadata_flag(getattr(artifact, "metadata", None), "adk_thought") + ) + grouped: list[tuple[str, str, bool]] = [] + for part in getattr(artifact, "parts", []): + text = str(getattr(part, "text", "") or "") + if not text: + continue + event_type = ( + "thinking" + if artifact_is_reasoning + or _metadata_flag(getattr(part, "metadata", None), "adk_thought") + else "text" + ) + replaces_output = _metadata_flag( + getattr(part, "metadata", None), + "ksadk_output_snapshot", + ) + if grouped and grouped[-1][0] == event_type: + grouped[-1] = ( + event_type, + grouped[-1][1] + text, + grouped[-1][2] or replaces_output, + ) + else: + grouped.append((event_type, text, replaces_output)) + + result: list[A2AStreamEvent] = [] + for event_type, text, replaces_output in grouped: + key = (artifact_id, event_type) + snapshot = snapshots.get(key, "") + if append: + delta = text + snapshots[key] = snapshot + text + replace = False + else: + delta = text[len(snapshot) :] if text.startswith(snapshot) else text + snapshots[key] = text + replace = replaces_output or bool(snapshot and not text.startswith(snapshot)) + if delta: + result.append( + { + "type": event_type, # type: ignore[typeddict-item] + "delta": delta, + "replace": replace, + } + ) + return result + + +def _metadata_flag(metadata: Any, key: str) -> bool: + if metadata is None: + return False + try: + return bool(metadata[key]) + except (KeyError, TypeError, ValueError): + return False + + +__all__ = [ + "A2AStreamEvent", + "stream_a2a_agent", + "stream_a2a_agent_events", + "stream_a2a_agent_to_writer", +] diff --git a/ksadk/a2a/resume_store.py b/ksadk/a2a/resume_store.py new file mode 100644 index 00000000..875aae89 --- /dev/null +++ b/ksadk/a2a/resume_store.py @@ -0,0 +1,201 @@ +"""Runtime-local storage for opaque A2A input-required recovery state.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import sqlite3 +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, TypeAlias + +from ksadk.runtime.adapter import ResumePayload, ResumeTarget, RunHandle + +A2AResumePayloadKind: TypeAlias = Literal[ + "tool_result", + "approval_decision", + "hitl_answer", + "free_text", +] + + +@dataclass(frozen=True) +class A2AResumeState: + """Private Runtime state needed to resume one protocol Task.""" + + handle: RunHandle + target: ResumeTarget + payload_kind: A2AResumePayloadKind + call_id: str | None = None + + +class A2AResumeStateStore(ABC): + """Persists recovery state outside public A2A Task metadata.""" + + @abstractmethod + async def initialize(self) -> None: + raise NotImplementedError + + @abstractmethod + async def put(self, key: str, state: A2AResumeState) -> None: + raise NotImplementedError + + @abstractmethod + async def get(self, key: str) -> A2AResumeState | None: + raise NotImplementedError + + @abstractmethod + async def delete(self, key: str) -> None: + raise NotImplementedError + + +class InMemoryA2AResumeStateStore(A2AResumeStateStore): + """Local-development store; it intentionally does not survive a restart.""" + + def __init__(self) -> None: + self._states: dict[str, A2AResumeState] = {} + + async def initialize(self) -> None: + return None + + async def put(self, key: str, state: A2AResumeState) -> None: + self._states[key] = state + + async def get(self, key: str) -> A2AResumeState | None: + return self._states.get(key) + + async def delete(self, key: str) -> None: + self._states.pop(key, None) + + +class SQLiteA2AResumeStateStore(A2AResumeStateStore): + """Crash-safe Runtime-local store for single-replica/persistent-volume deployments.""" + + def __init__(self, path: str | os.PathLike[str]) -> None: + self._path = Path(path) + self._initialized = False + self._init_lock = asyncio.Lock() + + @property + def path(self) -> Path: + return self._path + + async def initialize(self) -> None: + if self._initialized: + return + async with self._init_lock: + if self._initialized: + return + await asyncio.to_thread(self._initialize_sync) + self._initialized = True + + async def put(self, key: str, state: A2AResumeState) -> None: + await self.initialize() + payload = json.dumps( + { + "handle": state.handle.model_dump(mode="json"), + "target": state.target.model_dump(mode="json"), + "payload_kind": state.payload_kind, + "call_id": state.call_id, + }, + separators=(",", ":"), + sort_keys=True, + ) + await asyncio.to_thread(self._put_sync, key, payload) + + async def get(self, key: str) -> A2AResumeState | None: + await self.initialize() + payload = await asyncio.to_thread(self._get_sync, key) + if payload is None: + return None + try: + raw = json.loads(payload) + return A2AResumeState( + handle=RunHandle.model_validate(raw["handle"]), + target=ResumeTarget.model_validate(raw["target"]), + payload_kind=ResumePayload.model_validate( + {"kind": raw["payload_kind"]} + ).kind, + call_id=str(raw["call_id"]) if raw.get("call_id") is not None else None, + ) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("A2A resume state is invalid") from exc + + async def delete(self, key: str) -> None: + await self.initialize() + await asyncio.to_thread(self._delete_sync, key) + + def _initialize_sync(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self._path, timeout=5) + try: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA synchronous=FULL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS ksadk_a2a_resume_states ( + resume_key_sha256 TEXT PRIMARY KEY, + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.commit() + finally: + connection.close() + os.chmod(self._path, 0o600) + + @staticmethod + def _hash(key: str) -> str: + return hashlib.sha256(key.encode("utf-8")).hexdigest() + + def _put_sync(self, key: str, payload: str) -> None: + connection = sqlite3.connect(self._path, timeout=5) + try: + connection.execute( + """ + INSERT INTO ksadk_a2a_resume_states(resume_key_sha256, state_json) + VALUES (?, ?) + ON CONFLICT(resume_key_sha256) DO UPDATE SET + state_json = excluded.state_json, + updated_at = CURRENT_TIMESTAMP + """, + (self._hash(key), payload), + ) + connection.commit() + finally: + connection.close() + + def _get_sync(self, key: str) -> str | None: + connection = sqlite3.connect(self._path, timeout=5) + try: + row = connection.execute( + "SELECT state_json FROM ksadk_a2a_resume_states WHERE resume_key_sha256 = ?", + (self._hash(key),), + ).fetchone() + return str(row[0]) if row is not None else None + finally: + connection.close() + + def _delete_sync(self, key: str) -> None: + connection = sqlite3.connect(self._path, timeout=5) + try: + connection.execute( + "DELETE FROM ksadk_a2a_resume_states WHERE resume_key_sha256 = ?", + (self._hash(key),), + ) + connection.commit() + finally: + connection.close() + + +__all__ = [ + "A2AResumeState", + "A2AResumePayloadKind", + "A2AResumeStateStore", + "InMemoryA2AResumeStateStore", + "SQLiteA2AResumeStateStore", +] diff --git a/ksadk/a2a/routes.py b/ksadk/a2a/routes.py new file mode 100644 index 00000000..c4ab5a8d --- /dev/null +++ b/ksadk/a2a/routes.py @@ -0,0 +1,105 @@ +"""A2A 协议路由装配 — create_runtime_app 的 A2A seam (goal-05 §8)。 + +契约 §8: + +```python +def create_runtime_app(config): + app = create_base_data_plane_app(config) + if config.a2a.enabled: + add_a2a_protocol_routes(app, config.a2a) + return app +``` + +A2A route、TaskStore、task adapter 和 card 构造**只有一份实现**,普通 runtime app +与 HarnessApp 共用;A2A 是数据面 route group,不承载注册副作用。``A2AConfig`` +保留给本地开发和协议一致性测试;AgentEngine 产品 Runtime 必须通过 +``AgentEngineA2ABootstrap`` 装配 durable storage、trusted ingress 和 egress guard。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional, Sequence + +from a2a.server.routes import add_a2a_routes_to_fastapi +from a2a.server.tasks import TaskStore +from a2a.types import AgentSkill +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import AsyncEngine + +from ksadk.a2a.card import build_agent_card +from ksadk.a2a.server import A2AProtocolServer +from ksadk.a2a.task_adapter import A2ARuntimeTaskAdapter +from ksadk.a2a.task_store import A2A_TASK_TABLE, A2AOwnerContextBuilder, build_a2a_task_store + + +@dataclass +class A2AConfig: + """本地/一致性测试使用的 A2A 协议装配配置(数据面 route group)。""" + + enabled: bool = False + base_url: str = "http://127.0.0.1:8000" + agent_name: str = "agent" + description: str = "" + version: str = "1.0.0" + skills: Sequence[str | AgentSkill] = field(default_factory=tuple) + streaming: bool = True + prefer_stream: bool = True + # 本地调试可显式开放 reasoning artifact;托管 Runtime 默认关闭,避免把 + # 模型内部推理写入公开 A2A Task。 + include_reasoning: bool = False + # durable task store:dsn(如 sqlite+aiosqlite:///.agentengine/a2a_tasks.db 或 + # postgresql+asyncpg://...)或外部传入 engine/task_store。 + task_store_dsn: Optional[str] = None + task_table: str = A2A_TASK_TABLE + create_table: bool = True + + +def add_a2a_protocol_routes( + app: FastAPI, + runner: Any, + config: A2AConfig, + *, + task_adapter: A2ARuntimeTaskAdapter, + task_store: Optional[TaskStore] = None, + engine: Optional[AsyncEngine] = None, + context_builder: A2AOwnerContextBuilder | None = None, +) -> A2AProtocolServer: + """把 A2A 协议路由(JSONRPC + HTTP+JSON + AgentCard)装配到 app。 + + 返回构建好的 :class:`A2AProtocolServer`(便于调用方取 request_handler / + task_store 做后续 subscribe/cancel 或测试)。 + """ + card = build_agent_card( + name=config.agent_name, + base_url=config.base_url, + description=config.description, + version=config.version, + skills=config.skills, + streaming=config.streaming, + ) + store = task_store or build_a2a_task_store( + dsn=config.task_store_dsn, + engine=engine, + table_name=config.task_table, + 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, + ) + add_a2a_routes_to_fastapi( + app, + agent_card_routes=server.agent_card_routes(), + jsonrpc_routes=server.jsonrpc_routes(), + rest_routes=server.rest_routes(), + ) + return server + + +__all__ = ["A2AConfig", "add_a2a_protocol_routes"] diff --git a/ksadk/a2a/server.py b/ksadk/a2a/server.py index 33270d76..7af79c5b 100644 --- a/ksadk/a2a/server.py +++ b/ksadk/a2a/server.py @@ -1,79 +1,126 @@ -"""High-level server helpers for exposing a KsADK runner over A2A.""" +"""A2AProtocolServer — 托管 Agent 的 A2A 协议数据面 (goal-05)。 + +契约 §3.2:``A2AProtocolServer`` = latest SDK route factories + AgentCard + +request handler + durable task store。装配进 create_runtime_app(§8), +**不再是独立 Starlette app**(旧 demo 的 ``A2AStarletteApplication`` 已废弃)。 + +路由由 ``ksadk.a2a.routes.add_a2a_protocol_routes`` 统一装配,一份实现, +普通 runtime app 与 HarnessApp 共用。 +""" from __future__ import annotations -from collections.abc import Sequence -from typing import Any +from typing import Any, cast -from a2a.server.apps import A2AStarletteApplication +from a2a.server.agent_execution import RequestContext, SimpleRequestContextBuilder +from a2a.server.context import ServerCallContext from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore -from starlette.applications import Starlette +from a2a.server.routes import ( + create_agent_card_routes, + create_jsonrpc_routes, + create_rest_routes, +) +from a2a.server.tasks import TaskStore +from a2a.types import AgentCard, SendMessageRequest, Task + +from ksadk.a2a.card import JSONRPC_PATH, REST_PATH_PREFIX +from ksadk.a2a.executor import A2ARuntimeExecutor +from ksadk.a2a.task_adapter import A2ARuntimeTaskAdapter +from ksadk.a2a.task_store import A2AOwnerContextBuilder + -from ksadk.a2a.card_builder import AgentCardBuilder -from ksadk.a2a.executor import KsAgentExecutor +class _DurableRequestContextBuilder(SimpleRequestContextBuilder): + """Recover the persisted context when a follow-up only carries task_id.""" + + def __init__(self, task_store: TaskStore) -> None: + super().__init__(should_populate_referred_tasks=False, task_store=task_store) + self._durable_task_store = task_store + + async def build( + self, + context: ServerCallContext, + params: SendMessageRequest | None = None, + task_id: str | None = None, + context_id: str | None = None, + task: Task | None = None, + ) -> RequestContext: + if task_id and (task is None or not context_id): + persisted = await self._durable_task_store.get(task_id, context) + if persisted is not None: + task = persisted + context_id = context_id or persisted.context_id + return await super().build( + context=context, + params=params, + task_id=task_id, + context_id=context_id, + task=task, + ) -class KsA2AServer: - """Expose a KsADK runner as an A2A-compatible ASGI app.""" +class A2AProtocolServer: + """把一个 ksadk runner 暴露为 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, - app_name: str, - url: str, - description: str = "", - skills: Sequence[str] | None = None, - version: str = "1.0.0", + *, + 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.app_name = app_name - self.url = url - self.description = description - self.skills = list(skills or []) - self.version = version - - self.agent_card = AgentCardBuilder( - name=app_name, - url=url, - description=description, - skills=self.skills, - version=version, - ).build() - self.executor = KsAgentExecutor(runner=runner) - self.task_store = InMemoryTaskStore() + 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( agent_executor=self.executor, - task_store=self.task_store, + task_store=task_store, + agent_card=agent_card, + request_context_builder=_DurableRequestContextBuilder(task_store), ) - self.application = A2AStarletteApplication( - agent_card=self.agent_card, - http_handler=self.request_handler, + + def agent_card_routes(self) -> list[Any]: + return cast(list[Any], create_agent_card_routes(self.agent_card)) + + def jsonrpc_routes(self) -> list[Any]: + return cast( + list[Any], + create_jsonrpc_routes( + self.request_handler, + rpc_url=JSONRPC_PATH, + context_builder=self.context_builder, + ), ) - def build(self, **kwargs: Any) -> Starlette: - """Build the Starlette application with the standard A2A routes.""" - return self.application.build(**kwargs) - - def add_routes_to_app(self, app: Starlette) -> None: - """Attach the A2A routes to an existing Starlette application.""" - self.application.add_routes_to_app(app) - - -def to_a2a( - runner: Any, - app_name: str, - url: str, - description: str = "", - skills: Sequence[str] | None = None, - version: str = "1.0.0", -) -> KsA2AServer: - """Convenience helper for building a KsA2AServer.""" - return KsA2AServer( - runner=runner, - app_name=app_name, - url=url, - description=description, - skills=skills, - version=version, - ) + def rest_routes(self) -> list[Any]: + return cast( + list[Any], + create_rest_routes( + self.request_handler, + path_prefix=REST_PATH_PREFIX, + context_builder=self.context_builder, + ), + ) + + +__all__ = ["A2AProtocolServer"] diff --git a/ksadk/a2a/space_client.py b/ksadk/a2a/space_client.py new file mode 100644 index 00000000..53b039e2 --- /dev/null +++ b/ksadk/a2a/space_client.py @@ -0,0 +1,1101 @@ +"""Space-scoped A2A discovery and authorized data-plane calls.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import uuid +from collections.abc import AsyncIterator, Mapping +from contextlib import AsyncExitStack, asynccontextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from http.cookies import CookieError, SimpleCookie +from typing import Any +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import httpx +from a2a.client import ClientCallContext, ClientConfig, create_client +from a2a.types import ( + AgentCard, + CancelTaskRequest, + GetTaskRequest, + Message, + Part, + Role, + SendMessageConfiguration, + SendMessageRequest, + SubscribeToTaskRequest, + TaskState, +) +from google.protobuf.json_format import MessageToDict, ParseDict + +from ksadk.a2a.control_plane import ( + ENV_A2A_CONTROL_PLANE_URL, + A2AControlPlane, + A2ARouteInterface, + CredentialInjection, + DiscoveredAgent, + InternalA2AControlPlaneClient, + PreparedA2AOperation, + SpaceAgentPage, +) +from ksadk.a2a.event_adapter import A2AEventAdapter +from ksadk.a2a.external_transport import A2AExternalTransport +from ksadk.a2a.ids import require_a2a_resource_id +from ksadk.a2a.task_event_dispatcher import A2ATaskEventDispatcher +from ksadk.a2a.task_event_outbox import ( + A2ATaskEventOutbox, + InMemoryA2ATaskEventOutbox, + SQLiteA2ATaskEventOutbox, +) +from ksadk.events.runtime_event import RuntimeEvent + +logger = logging.getLogger(__name__) + +ENV_A2A_SPACE_IDS = "KSADK_A2A_SPACE_IDS" +ENV_A2A_ENABLE_PUBLIC_EGRESS = "KSADK_A2A_ENABLE_PUBLIC_EGRESS" + +ERR_PUBLIC_EGRESS_DISABLED = "A2A_PUBLIC_EGRESS_DISABLED" +MAX_A2A_MESSAGE_BYTES = 1024 * 1024 +MAX_A2A_MESSAGE_PARTS = 64 +MAX_A2A_MESSAGE_ID_LENGTH = 128 + + +@dataclass(frozen=True) +class A2APlatformTask: + """AgentEngine task locator with an optional latest remote A2A snapshot.""" + + id: str + remote_task: Any | None = None + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _canonical_proto(value: Any) -> dict[str, Any]: + return MessageToDict(value, preserving_proto_field_name=False) + + +def _canonical_sha256(value: Any) -> str: + payload = _canonical_proto(value) + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _present_message_field(value: Any, field_name: str) -> Any | None: + has_field = getattr(value, "HasField", None) + if callable(has_field): + try: + if not has_field(field_name): + return None + except ValueError: + pass + return getattr(value, field_name, None) + + +class A2ASpaceClient: + """Discovers Space members and performs permit-authorized A2A calls.""" + + def __init__( + self, + space_id: str, + backend: A2AControlPlane, + *, + egress_enabled: bool = False, + httpx_client: httpx.AsyncClient | None = None, + external_transport: A2AExternalTransport | None = None, + event_sink: Any | None = None, + event_outbox: A2ATaskEventOutbox | None = None, + event_dispatcher: A2ATaskEventDispatcher | None = None, + ) -> None: + require_a2a_resource_id(space_id, "a2a-space-", field_name="space_id") + if external_transport is not None and not isinstance( + external_transport, A2AExternalTransport + ): + raise TypeError("external_transport must implement A2AExternalTransport") + self._space_id = space_id + self._backend = backend + self._egress_enabled = egress_enabled + self._httpx_client = httpx_client + self._external_transport = external_transport + self._event_sink = event_sink + if event_dispatcher is not None and event_outbox is not None: + raise ValueError("pass either event_dispatcher or event_outbox, not both") + self._owns_event_dispatcher = event_dispatcher is None + self._event_dispatcher = event_dispatcher or A2ATaskEventDispatcher( + event_outbox or InMemoryA2ATaskEventOutbox(), + backend, + ) + self._event_adapter = A2AEventAdapter() + self._agents_by_id: dict[str, DiscoveredAgent] = {} + self._agents_by_task: dict[str, DiscoveredAgent] = {} + self._seq = 0 + self._persisted_wire_events: set[str] = set() + + @classmethod + def from_env( + cls, + *, + space_id: str | None = None, + backend: A2AControlPlane | None = None, + httpx_client: httpx.AsyncClient | None = None, + external_transport: A2AExternalTransport | None = None, + egress_enabled: bool | None = None, + event_sink: Any | None = None, + event_outbox: A2ATaskEventOutbox | None = None, + event_dispatcher: A2ATaskEventDispatcher | None = None, + ) -> "A2ASpaceClient": + selected_space_id = str(space_id or "").strip() + if selected_space_id: + require_a2a_resource_id( + selected_space_id, + "a2a-space-", + 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" + ) + try: + configured_space_ids = json.loads(raw_space_ids) + except json.JSONDecodeError as exc: + raise ValueError(f"{ENV_A2A_SPACE_IDS} must be a JSON string array") from exc + if not isinstance(configured_space_ids, list) or not configured_space_ids: + raise ValueError(f"{ENV_A2A_SPACE_IDS} must be a non-empty JSON string array") + if len(configured_space_ids) > 100: + raise ValueError(f"{ENV_A2A_SPACE_IDS} cannot contain more than 100 Space IDs") + normalized_space_ids: list[str] = [] + for index, configured_space_id in enumerate(configured_space_ids): + 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_space_ids.append(normalized) + if len(set(normalized_space_ids)) != len(normalized_space_ids): + raise ValueError(f"{ENV_A2A_SPACE_IDS} must not contain duplicate Space IDs") + if len(normalized_space_ids) != 1: + raise ValueError( + f"{ENV_A2A_SPACE_IDS} contains multiple Space IDs; pass space_id explicitly" + ) + 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, + httpx_client=httpx_client, + ) + if egress_enabled is None: + raw_egress = os.getenv(ENV_A2A_ENABLE_PUBLIC_EGRESS) or "" + egress_enabled = raw_egress.strip().lower() in {"1", "true", "yes", "on"} + if event_outbox is None and event_dispatcher is None: + event_outbox = SQLiteA2ATaskEventOutbox() + return cls( + selected_space_id, + backend, + egress_enabled=egress_enabled, + httpx_client=httpx_client, + external_transport=external_transport, + event_sink=event_sink, + event_outbox=event_outbox, + event_dispatcher=event_dispatcher, + ) + + async def discover( + self, + prompt: str | None = None, + *, + skill: str | None = None, + include_blocked: bool = False, + ) -> list[DiscoveredAgent]: + page = await self._backend.list_space_agents( + self._space_id, + prompt=prompt, + skill_id=skill, + include_blocked=include_blocked, + ) + for agent in page.agents: + require_a2a_resource_id( + agent.agent_id, + "a2a-agent-", + field_name="DiscoveredAgent.agent_id", + ) + require_a2a_resource_id( + agent.version_id, + "a2a-version-", + field_name="DiscoveredAgent.version_id", + ) + self._agents_by_id[agent.agent_id] = agent + return page.agents + + def _check_egress(self, agent: DiscoveredAgent) -> None: + if agent.route_kind == "external_public" and not self._egress_enabled: + raise PermissionError( + f"{ERR_PUBLIC_EGRESS_DISABLED}: external Agent {agent.agent_id} requires " + "Network.EnablePublicAccess" + ) + + @property + def event_dispatcher(self) -> A2ATaskEventDispatcher: + """Runtime-scoped task event dispatcher used by this client.""" + + return self._event_dispatcher + + async def aclose(self, *, flush_timeout_seconds: float = 5.0) -> None: + """Stop the dispatcher only when this standalone client created it.""" + + if self._owns_event_dispatcher: + await self._event_dispatcher.stop(flush_timeout_seconds=flush_timeout_seconds) + + async def __aenter__(self) -> "A2ASpaceClient": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.aclose() + + async def _resolve_agent(self, agent_id: str) -> DiscoveredAgent: + agent = self._agents_by_id.get(agent_id) + if agent is None: + await self.discover() + agent = self._agents_by_id.get(agent_id) + if agent is None: + raise KeyError(f"Agent {agent_id!r} is not discoverable in Space {self._space_id}") + self._check_egress(agent) + return agent + + async def send_message( + self, + agent_id: str, + message: str | Message, + *, + return_immediately: bool = False, + idempotency_token: str | None = None, + ) -> A2APlatformTask: + agent = await self._resolve_agent(agent_id) + normalized = self._normalize_initial_message(message) + prepared = await self._backend.prepare_call( + space_id=self._space_id, + target_agent_id=agent.agent_id, + expected_version_id=agent.version_id, + message_id=normalized.message_id, + message_sha256=_canonical_sha256(normalized), + idempotency_token=idempotency_token or f"idem-{uuid.uuid4().hex}", + ) + self._validate_prepared_target(agent, prepared) + handle = await self._send_prepared_message( + prepared, + agent, + normalized, + return_immediately=return_immediately, + ) + self._agents_by_task[prepared.platform_task_id] = agent + await self._record_agent_for_task(prepared.platform_task_id, agent) + return handle + + async def continue_task( + self, + task_id: str, + message: str | Message, + *, + return_immediately: bool = False, + idempotency_token: str | None = None, + ) -> A2APlatformTask: + require_a2a_resource_id(task_id, "a2a-task-", field_name="task_id") + normalized = self._normalize_initial_message(message) + prepared = await self._backend.prepare_task_operation( + platform_task_id=task_id, + operation="send_message", + message_id=normalized.message_id, + message_sha256=_canonical_sha256(normalized), + idempotency_token=idempotency_token or f"idem-{uuid.uuid4().hex}", + ) + self._validate_prepared_ids(prepared) + remote_task = self._require_remote_task(prepared) + normalized.task_id = remote_task.remote_task_id + if remote_task.remote_context_id: + normalized.context_id = remote_task.remote_context_id + agent = self._agent_from_prepared(prepared) + return await self._send_prepared_message( + prepared, + agent, + normalized, + return_immediately=return_immediately, + ) + + async def _send_prepared_message( + self, + prepared: PreparedA2AOperation, + agent: DiscoveredAgent, + message: Message, + *, + return_immediately: bool, + ) -> A2APlatformTask: + first_task = None + remote_task_id = prepared.remote_task.remote_task_id if prepared.remote_task else None + remote_context_id = prepared.remote_task.remote_context_id if prepared.remote_task else None + operation_instance_id = self._operation_instance_id(prepared) + async with self._operation_client(agent, prepared) as (client, context): + request = SendMessageRequest( + message=message, + configuration=SendMessageConfiguration(return_immediately=return_immediately), + ) + wire_position = 0 + 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 + observed_task_id = str(response_task.id) + observed_context_id = str(response_task.context_id or "") or None + if remote_task_id is None: + await self._bind_task(prepared.platform_task_id, response_task) + elif (remote_task_id, remote_context_id) != ( + observed_task_id, + observed_context_id, + ): + raise RuntimeError("A2A_REMOTE_BINDING_CONFLICT") + remote_task_id = observed_task_id + remote_context_id = observed_context_id + if remote_task_id is not None: + self._validate_remote_task_observation( + remote_task_id, + remote_context_id, + response, + ) + await self._project_stream_item( + prepared.platform_task_id, + response, + agent, + wire_position=wire_position, + operation_instance_id=operation_instance_id, + ) + wire_position += 1 + if return_immediately and first_task is not None: + break + return A2APlatformTask( + id=prepared.platform_task_id, + remote_task=first_task, + ) + + async def subscribe(self, task_id: str): + require_a2a_resource_id(task_id, "a2a-task-", field_name="task_id") + prepared = await self._backend.prepare_task_operation( + platform_task_id=task_id, + operation="subscribe_to_task", + ) + self._validate_prepared_ids(prepared) + agent = self._agent_from_prepared(prepared) + async for item, _ in self._iter_subscription(prepared, agent): + yield item + + async def _iter_subscription( + self, + prepared: PreparedA2AOperation, + agent: DiscoveredAgent, + ): + remote_task = self._require_remote_task(prepared) + operation_instance_id = self._operation_instance_id(prepared) + async with self._operation_client(agent, prepared) as (client, context): + wire_position = 0 + async for event in client.subscribe( + SubscribeToTaskRequest(id=remote_task.remote_task_id), + context=context, + ): + self._validate_remote_task_observation( + remote_task.remote_task_id, + remote_task.remote_context_id, + event, + ) + persisted = await self._project_stream_item( + prepared.platform_task_id, + event, + agent, + wire_position=wire_position, + operation_instance_id=operation_instance_id, + ) + wire_position += 1 + yield event, persisted + + async def cancel( + self, + task_id: str, + *, + idempotency_token: str | None = None, + ) -> A2APlatformTask: + require_a2a_resource_id(task_id, "a2a-task-", field_name="task_id") + prepared = await self._backend.prepare_task_operation( + platform_task_id=task_id, + operation="cancel_task", + idempotency_token=idempotency_token or f"idem-{uuid.uuid4().hex}", + ) + self._validate_prepared_ids(prepared) + remote_task_ref = self._require_remote_task(prepared) + agent = self._agent_from_prepared(prepared) + async with self._operation_client(agent, prepared) as (client, context): + remote_task = await client.cancel_task( + CancelTaskRequest(id=remote_task_ref.remote_task_id), context=context + ) + self._validate_remote_task_observation( + remote_task_ref.remote_task_id, + remote_task_ref.remote_context_id, + remote_task, + ) + await self._project_stream_item( + task_id, + remote_task, + agent, + wire_position=0, + operation_instance_id=self._operation_instance_id(prepared), + ) + return A2APlatformTask( + id=task_id, + remote_task=remote_task, + ) + + async def get_task(self, task_id: str) -> A2APlatformTask: + require_a2a_resource_id(task_id, "a2a-task-", field_name="task_id") + prepared = await self._backend.prepare_task_operation( + platform_task_id=task_id, + operation="get_task", + ) + self._validate_prepared_ids(prepared) + remote_task_ref = self._require_remote_task(prepared) + agent = self._agent_from_prepared(prepared) + async with self._operation_client(agent, prepared) as (client, context): + remote_task = await client.get_task( + GetTaskRequest(id=remote_task_ref.remote_task_id), context=context + ) + self._validate_remote_task_observation( + remote_task_ref.remote_task_id, + remote_task_ref.remote_context_id, + remote_task, + ) + await self._project_stream_item( + task_id, + remote_task, + agent, + wire_position=0, + operation_instance_id=self._operation_instance_id(prepared), + ) + return A2APlatformTask( + id=task_id, + remote_task=remote_task, + ) + + def _normalize_initial_message(self, message: str | Message) -> Message: + if isinstance(message, str): + message = Message( + role=Role.ROLE_USER, + parts=[Part(text=message)], + message_id=f"message-{uuid.uuid4().hex}", + ) + if getattr(message, "task_id", "") or getattr(message, "context_id", ""): + raise ValueError("caller must not provide remote task_id/context_id") + if message.role != Role.ROLE_USER: + raise ValueError("A2A Message role must be user") + if not 1 <= len(message.parts) <= MAX_A2A_MESSAGE_PARTS: + raise ValueError("A2A Message parts must contain 1-64 items") + if not getattr(message, "message_id", ""): + message.message_id = f"message-{uuid.uuid4().hex}" + if len(message.message_id) > MAX_A2A_MESSAGE_ID_LENGTH: + raise ValueError("A2A Message message_id must contain 1-128 characters") + encoded = json.dumps( + _canonical_proto(message), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > MAX_A2A_MESSAGE_BYTES: + raise ValueError("A2A Message canonical JSON exceeds 1 MiB") + return message + + @staticmethod + def _validate_prepared_target( + agent: DiscoveredAgent, + prepared: PreparedA2AOperation, + ) -> None: + A2ASpaceClient._validate_prepared_ids(prepared) + if prepared.target.agent_id != agent.agent_id: + raise RuntimeError("PrepareA2ACall returned a different target Agent") + if prepared.target.version_id != agent.version_id: + raise RuntimeError("PrepareA2ACall returned a different target version") + if agent.card_sha256 and prepared.target.card_sha256 != agent.card_sha256: + raise RuntimeError("PrepareA2ACall returned a different AgentCard hash") + + @staticmethod + def _validate_prepared_ids(prepared: PreparedA2AOperation) -> None: + require_a2a_resource_id( + prepared.platform_task_id, + "a2a-task-", + field_name="PreparedA2AOperation.platform_task_id", + ) + require_a2a_resource_id( + prepared.target.agent_id, + "a2a-agent-", + field_name="PreparedA2AOperation.target.agent_id", + ) + require_a2a_resource_id( + prepared.target.version_id, + "a2a-version-", + field_name="PreparedA2AOperation.target.version_id", + ) + + @staticmethod + def _require_remote_task(prepared: PreparedA2AOperation): + remote_task = prepared.remote_task + if remote_task is None or not remote_task.remote_task_id: + raise RuntimeError("A2A_REMOTE_TASK_NOT_BOUND") + return remote_task + + @staticmethod + def _operation_instance_id(prepared: PreparedA2AOperation) -> str: + return hashlib.sha256(prepared.call_permit.encode("utf-8")).hexdigest() + + @staticmethod + def _validate_remote_task_observation( + expected_task_id: str, + expected_context_id: str | None, + item: Any, + ) -> None: + task = _present_message_field(item, "task") + if task is None and hasattr(item, "status") and hasattr(item, "id"): + task = item + status_update = _present_message_field(item, "status_update") + artifact_update = _present_message_field(item, "artifact_update") + message = _present_message_field(item, "message") + candidate = task or status_update or artifact_update or message + if candidate is None: + return + observed_task_id = str( + getattr(candidate, "id", "") or getattr(candidate, "task_id", "") or "" + ) + observed_context_id = str(getattr(candidate, "context_id", "") or "") or None + if observed_task_id and observed_task_id != expected_task_id: + raise RuntimeError("A2A_REMOTE_BINDING_CONFLICT") + if observed_context_id != expected_context_id: + raise RuntimeError("A2A_REMOTE_BINDING_CONFLICT") + + def _agent_from_prepared(self, prepared: PreparedA2AOperation) -> DiscoveredAgent: + cached = self._agents_by_id.get(prepared.target.agent_id) + if cached is not None and cached.version_id == prepared.target.version_id: + return cached + card = self._route_only_card(prepared.target.agent_id, prepared.target.version_id) + return DiscoveredAgent( + agent_id=prepared.target.agent_id, + version_id=prepared.target.version_id, + source="hosted" if prepared.route.kind == "hosted_gateway" else "external", + agent_card=card, + card_sha256=prepared.target.card_sha256, + route_kind=prepared.route.kind, + ) + + def _route_only_card(self, name: str, version: str) -> AgentCard: + return ParseDict( + { + "name": name, + "description": "AgentEngine prepared A2A route", + "version": version, + "supportedInterfaces": [], + "capabilities": {}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [], + }, + AgentCard(), + ) + + @asynccontextmanager + async def _operation_client( + self, + agent: DiscoveredAgent, + prepared: PreparedA2AOperation, + ) -> AsyncIterator[tuple[Any, ClientCallContext]]: + # Product bootstrap starts the shared dispatcher during lifespan startup. + # Standalone/from_env clients own their dispatcher and must start it here. + await self._event_dispatcher.start() + injection = CredentialInjection() + headers: dict[str, str] + async with AsyncExitStack() as exit_stack: + if prepared.route.kind == "hosted_gateway": + headers = { + "Authorization": f"Bearer {self._backend.gateway_token()}", + "X-AgentEngine-A2A-Permit": prepared.call_permit, + } + http = self._httpx_client + else: + if prepared.route.kind == "external_public" and not self._egress_enabled: + raise PermissionError(ERR_PUBLIC_EGRESS_DISABLED) + if self._external_transport is None: + raise RuntimeError( + "A2A_EGRESS_TRANSPORT_REQUIRED: external calls require a Runtime network " + "guard transport" + ) + lease = await exit_stack.enter_async_context( + self._external_transport.open_for_route( + prepared.route.interface, + route_kind=prepared.route.kind, + ) + ) + http = lease.httpx_client + injection = await self._backend.resolve_credential( + platform_task_id=prepared.platform_task_id, + credential_handle=prepared.credential_handle, + call_permit=prepared.call_permit, + ) + headers = dict(injection.headers) + if injection.cookies: + if any(name.lower() == "cookie" for name in headers): + raise RuntimeError( + "A2A_CREDENTIAL_INJECTION_CONFLICT: Cookie header and cookie injection " + "cannot both be present" + ) + headers["Cookie"] = self._cookie_header(injection.cookies) + route = prepared.route.interface + if injection.query: + route = A2ARouteInterface( + url=self._url_with_query(route.url, injection.query), + protocol_binding=route.protocol_binding, + protocol_version=route.protocol_version, + ) + route_card = self._card_for_route(agent.agent_card, route) + owned_http = None + if http is None: + owned_http = httpx.AsyncClient(trust_env=False) + http = owned_http + client = await create_client( + agent=route_card, + client_config=ClientConfig(httpx_client=http, streaming=True), + ) + try: + yield client, ClientCallContext(service_parameters=headers or None) + finally: + if owned_http is not None: + await client.close() + await owned_http.aclose() + + @staticmethod + def _url_with_query(url: str, query: dict[str, str]) -> str: + parsed = urlsplit(url) + values = parse_qsl(parsed.query, keep_blank_values=True) + existing = {name for name, _ in values} + collision = existing.intersection(query) + if collision: + raise RuntimeError( + "A2A_CREDENTIAL_INJECTION_CONFLICT: credential query collides with route query: " + f"{sorted(collision)}" + ) + values.extend(query.items()) + return urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, urlencode(values), parsed.fragment) + ) + + @staticmethod + def _cookie_header(cookies: dict[str, str]) -> str: + jar = SimpleCookie() + try: + for name, value in cookies.items(): + jar[name] = value + except CookieError as exc: + raise RuntimeError( + "A2A_CREDENTIAL_INJECTION_CONFLICT: invalid credential cookie" + ) from exc + return jar.output(header="", sep="; ").strip() + + @staticmethod + def _card_for_route(card: AgentCard, route: A2ARouteInterface) -> AgentCard: + payload = _canonical_proto(card) + payload["supportedInterfaces"] = [ + { + "url": route.url, + "protocolBinding": route.protocol_binding, + "protocolVersion": route.protocol_version, + } + ] + return ParseDict(payload, AgentCard()) + + async def _bind_task(self, platform_task_id: str, remote_task: Any) -> None: + await self._backend.bind_remote_task( + platform_task_id=platform_task_id, + remote_task_id=str(remote_task.id), + remote_context_id=str(remote_task.context_id or "") or None, + observed_at=_utc_now(), + ) + + async def _project_stream_item( + self, + platform_task_id: str, + item: Any, + agent: DiscoveredAgent, + *, + wire_position: int, + operation_instance_id: str, + ) -> list[RuntimeEvent]: + runtime_events = self._stream_item_to_events( + item, + agent, + wire_position=wire_position, + invocation_id=platform_task_id, + ) + platform_events = self._platform_events( + item, + platform_task_id, + operation_instance_id=operation_instance_id, + wire_position=wire_position, + ) + if platform_events: + await self._event_dispatcher.enqueue( + platform_task_id=platform_task_id, + events=platform_events, + ) + return await self._persist_events(runtime_events) + + def _platform_events( + self, + item: Any, + platform_task_id: str, + *, + operation_instance_id: str, + wire_position: int, + ) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + + def append_event( + kind: str, + payload: dict[str, Any], + *, + status: str | None = None, + occurred_at: str | None = None, + ) -> None: + events.append( + self._platform_event( + kind, + payload, + platform_task_id, + operation_instance_id=operation_instance_id, + wire_position=wire_position, + event_ordinal=len(events), + status=status, + occurred_at=occurred_at, + ) + ) + + task = _present_message_field(item, "task") + if task is None and hasattr(item, "status") and hasattr(item, "id"): + task = item + status_update = _present_message_field(item, "status_update") + artifact_update = _present_message_field(item, "artifact_update") + message = _present_message_field(item, "message") + if task is not None and getattr(task, "status", None) is not None: + payload = _canonical_proto(task.status) + state_name = TaskState.Name(task.status.state) + append_event( + "status", + payload, + status=state_name.removeprefix("TASK_STATE_").lower(), + occurred_at=str(payload.get("timestamp") or _utc_now()), + ) + for artifact in getattr(task, "artifacts", None) or []: + append_event( + "artifact", + { + "Artifact": _canonical_proto(artifact), + "Append": False, + "LastChunk": True, + }, + ) + if status_update is not None and getattr(status_update, "status", None) is not None: + payload = _canonical_proto(status_update.status) + state_name = TaskState.Name(status_update.status.state) + append_event( + "status", + payload, + status=state_name.removeprefix("TASK_STATE_").lower(), + occurred_at=str(payload.get("timestamp") or _utc_now()), + ) + if artifact_update is not None and getattr(artifact_update, "artifact", None) is not None: + append_event( + "artifact", + { + "Artifact": _canonical_proto(artifact_update.artifact), + "Append": bool(getattr(artifact_update, "append", False)), + "LastChunk": bool(getattr(artifact_update, "last_chunk", False)), + }, + ) + if message is not None: + payload = _canonical_proto(message) + append_event("message", payload) + append_event( + "status", + {"state": "TASK_STATE_COMPLETED", "message": payload}, + status="completed", + ) + return events + + @staticmethod + def _platform_event( + kind: str, + payload: dict[str, Any], + platform_task_id: str, + *, + operation_instance_id: str, + wire_position: int, + event_ordinal: int, + status: str | None = None, + occurred_at: str | None = None, + ) -> dict[str, Any]: + source_id = hashlib.sha256( + ( + f"{platform_task_id}:{operation_instance_id}:{wire_position}:{event_ordinal}:{kind}" + ).encode("utf-8") + ).hexdigest() + event: dict[str, Any] = { + "SourceEventId": source_id, + "EventKind": kind, + "Payload": payload, + "OccurredAt": occurred_at or _utc_now(), + } + if status: + event["Status"] = status + return event + + async def flush_pending_events(self) -> int: + """Deliver all currently queued platform event batches or raise on failure.""" + + return await self._event_dispatcher.drain(raise_on_error=True) + + def _next_seq(self) -> int: + self._seq += 1 + return self._seq + + def _event_ctx( + self, + agent: DiscoveredAgent, + invocation_id: str, + *, + event_id: str | None = None, + ) -> dict[str, Any]: + return { + "agent_id": agent.agent_id, + "user_id": "a2a_space", + "session_id": self._space_id, + "invocation_id": invocation_id, + "seq_id": self._next_seq(), + "event_id": event_id, + } + + def task_to_event(self, task: Any, agent: DiscoveredAgent) -> RuntimeEvent: + return self._event_adapter.task_status_to_event( + task.status, **self._event_ctx(agent, invocation_id=str(task.id)) + ) + + def _stream_item_to_events( + self, + item: Any, + agent: DiscoveredAgent, + *, + wire_position: int = 0, + invocation_id: str | None = None, + ) -> list[RuntimeEvent]: + events: list[RuntimeEvent] = [] + task = _present_message_field(item, "task") + if task is None and hasattr(item, "status") and hasattr(item, "id"): + task = item + status_update = _present_message_field(item, "status_update") + artifact_update = _present_message_field(item, "artifact_update") + message = _present_message_field(item, "message") + resolved_invocation_id = invocation_id or str( + getattr(item, "task_id", None) + or getattr(task, "id", "") + or getattr(status_update, "task_id", "") + or getattr(artifact_update, "task_id", "") + or getattr(message, "task_id", "") + or "" + ) + + def ctx(kind: str, value: Any) -> dict[str, Any]: + metadata = getattr(value, "metadata", None) + native_event_id = "" + if metadata is not None: + if isinstance(metadata, Mapping): + metadata_dict = dict(metadata) + else: + try: + metadata_dict = MessageToDict(metadata, preserving_proto_field_name=True) + except (AttributeError, TypeError, ValueError): + metadata_dict = {} + native_event_id = str( + metadata_dict.get("event_id") or metadata_dict.get("ksadk_event_id") or "" + ) + message_id = str(getattr(value, "message_id", "") or "") + artifact = getattr(value, "artifact", None) + artifact_id = str( + getattr(value, "artifact_id", "") or getattr(artifact, "artifact_id", "") or "" + ) + source_id = native_event_id or message_id or artifact_id + event_id = uuid.uuid5( + uuid.NAMESPACE_URL, + f"ksadk:a2a:{resolved_invocation_id}:{wire_position}:{kind}:{source_id}", + ).hex + return self._event_ctx(agent, invocation_id=resolved_invocation_id, event_id=event_id) + + if task is not None and getattr(task, "status", None) is not None: + task_status_message = _present_message_field(task.status, "message") + task_status_text = A2AEventAdapter._parts_text( + getattr(task_status_message, "parts", None) + ) + task_is_terminal = task.status.state in { + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + } + if not task_is_terminal: + events.append( + self._event_adapter.task_status_to_event( + task.status, + **ctx("task", task), + ) + ) + if task_status_text: + events.append( + self._event_adapter.message_to_event( + task_status_text, + final=task_is_terminal, + **ctx("task-status-message", task_status_message), + ) + ) + if task_is_terminal: + events.append( + self._event_adapter.task_status_to_event( + task.status, + **ctx("task", task), + ) + ) + if status_update is not None and getattr(status_update, "status", None) is not None: + status_message = _present_message_field(status_update.status, "message") + text = A2AEventAdapter._parts_text(getattr(status_message, "parts", None)) + terminal_states = { + TaskState.TASK_STATE_COMPLETED, + TaskState.TASK_STATE_FAILED, + TaskState.TASK_STATE_CANCELED, + TaskState.TASK_STATE_REJECTED, + } + is_terminal = status_update.status.state in terminal_states + if not is_terminal: + events.append( + self._event_adapter.task_status_to_event( + status_update.status, **ctx("status", status_update) + ) + ) + if text: + events.append( + self._event_adapter.message_to_event( + text, + final=is_terminal, + **ctx("status-message", status_message), + ) + ) + if is_terminal: + events.append( + self._event_adapter.task_status_to_event( + status_update.status, **ctx("status", status_update) + ) + ) + if artifact_update is not None and getattr(artifact_update, "artifact", None) is not None: + artifact = artifact_update.artifact + events.append( + self._event_adapter.artifact_to_event(artifact, **ctx("artifact", artifact_update)) + ) + artifact_text = A2AEventAdapter._parts_text(getattr(artifact, "parts", None)) + if artifact_text and str(getattr(artifact, "name", "") or "") == "response": + events.append( + self._event_adapter.message_to_event( + artifact_text, + final=bool(getattr(artifact_update, "last_chunk", False)), + **ctx("artifact-text", artifact_update), + ) + ) + if message is not None: + text = A2AEventAdapter._parts_text(getattr(message, "parts", None)) + if text: + events.append( + self._event_adapter.message_to_event( + text, final=True, **ctx("message", message) + ) + ) + return events + + async def _persist_events(self, events: list[RuntimeEvent]) -> list[RuntimeEvent]: + existing_ids = set(self._persisted_wire_events) + if self._event_sink is not None: + list_events = getattr(self._event_sink, "list", None) + if callable(list_events) and events: + persisted_before = await list_events(events[0].session_id) + existing_ids.update(event.event_id for event in persisted_before) + fresh = [event for event in events if event.event_id not in existing_ids] + if not fresh: + return [] + if self._event_sink is not None: + append = getattr(self._event_sink, "append", None) + if append is None: + raise TypeError("event_sink must provide async append(events)") + persisted = await append(fresh) + if persisted is not None: + fresh = list(persisted) + self._persisted_wire_events.update(event.event_id for event in fresh) + return fresh + + async def subscribe_events(self, task_id: str): + require_a2a_resource_id(task_id, "a2a-task-", field_name="task_id") + prepared = await self._backend.prepare_task_operation( + platform_task_id=task_id, + operation="subscribe_to_task", + ) + self._validate_prepared_ids(prepared) + agent = self._agent_from_prepared(prepared) + async for _, persisted in self._iter_subscription(prepared, agent): + for event in persisted: + yield event + + async def subscribe_persisted_events( + self, + *, + after_seq_id: int = 0, + timeout: float = 1.0, + ) -> AsyncIterator[RuntimeEvent]: + subscribe = getattr(self._event_sink, "subscribe_session", None) + if subscribe is None: + raise RuntimeError("event_sink does not support subscribe_session cursor replay") + async for event in subscribe( + self._space_id, + after_seq_id=after_seq_id, + timeout=timeout, + ): + yield event + + async def _record_agent_for_task(self, task_id: str, agent: DiscoveredAgent) -> None: + set_task_agent = getattr(self._event_sink, "set_task_agent", None) + if callable(set_task_agent): + await set_task_agent(self._space_id, task_id, agent.agent_id) + + +__all__ = [ + "A2AExternalTransport", + "A2APlatformTask", + "A2ASpaceClient", + "DiscoveredAgent", + "ENV_A2A_ENABLE_PUBLIC_EGRESS", + "ENV_A2A_SPACE_IDS", + "ERR_PUBLIC_EGRESS_DISABLED", + "SpaceAgentPage", +] diff --git a/ksadk/a2a/task_adapter.py b/ksadk/a2a/task_adapter.py new file mode 100644 index 00000000..7ff3a0ea --- /dev/null +++ b/ksadk/a2a/task_adapter.py @@ -0,0 +1,423 @@ +"""A2ARuntimeTaskAdapter — A2A Task 与 Runtime session/run/checkpoint/cancel 的映射 (goal-05 §7.2)。 + +| A2A | Runtime | +|---|---| +| ``context_id`` | ``session_id`` | +| ``task_id`` | SDK TaskStore 主键;run/checkpoint 引用保存在 Runtime-local resume store | +| working | active invocation | +| input-required | pending interaction + checkpoint/resume token | +| canceled | ``RuntimeAdapter.cancel(invocation_id)`` 成功后的终态 | +| artifact/message | RuntimeEvent artifact/text/data | + +cancel 一律走 G0.3 冻结的 ``RuntimeAdapter.cancel``(含 pending-cancel 语义), +不在 executor/本模块自造一套。 +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator, Mapping +from typing import Any, Optional + +from google.protobuf.json_format import MessageToDict +from google.protobuf.message import Message as ProtobufMessage + +from ksadk.a2a.context_store import A2AContextStore +from ksadk.a2a.identity import A2AIngressIdentity +from ksadk.a2a.resume_store import ( + A2AResumePayloadKind, + A2AResumeState, + A2AResumeStateStore, + InMemoryA2AResumeStateStore, +) +from ksadk.events import RuntimeEvent +from ksadk.runtime.adapter import ( + CancelResult, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeAdapter, + StartRequest, +) + +logger = logging.getLogger(__name__) + + +class A2ARuntimeTaskAdapter: + """A2A Task ↔ Runtime 的映射器。 + + 持有一个 G0.3 ``RuntimeAdapter``(由 runtime registry / runner bridge 提供), + 把 A2A 侧的 task/cancel/input-required 映射到 Runtime 六动词。 + """ + + def __init__( + self, + runtime_adapter: RuntimeAdapter, + *, + runtime_type: str = "local", + context_store: A2AContextStore | None = None, + resume_state_store: A2AResumeStateStore | None = None, + ) -> None: + self._adapter = runtime_adapter + self._runtime_type = runtime_type + self._context_store = context_store + self._resume_state_store = resume_state_store or InMemoryA2AResumeStateStore() + self._handles_by_task_key: dict[tuple[str, str, str], RunHandle] = {} + self._accepted_canceled_tasks: set[tuple[str, str, str]] = set() + + @property + def runtime_adapter(self) -> RuntimeAdapter: + return self._adapter + + # ---- 映射:task_id/context_id ↔ session/invocation ---- + + @staticmethod + def session_id_from_context(context_id: Optional[str]) -> str: + """§7.2: A2A context_id ↔ Runtime session_id。""" + return str(context_id or "") + + def handle_for_task( + self, + *, + task_id: str, + invocation_id: str, + session_id: str, + native_ref: Optional[dict[str, Any]] = None, + ) -> RunHandle: + """从 Task metadata 还原 Runtime 句柄(run_id = invocation_id)。""" + return RunHandle( + run_id=invocation_id or task_id, + session_id=session_id, + runtime_type=self._runtime_type, + native_ref=native_ref or {}, + ) + + # ---- cancel:走 RuntimeAdapter.cancel ---- + + async def cancel_task(self, task_id: str, context: Any) -> CancelResult: + """取消 A2A task → RuntimeAdapter.cancel。 + + 只使用 ``start`` 返回的进程内真实 handle,或 Runtime-local resume store 中 + 持久化的 handle;找不到时返回 NOT_RUNNING,不按 task_id 构造假 handle。 + """ + await self.prepare_context(context) + task_key = self._task_key(task_id, context) + handle = self._handles_by_task_key.get(task_key) + restored = handle is None + if handle is None: + handle = await self._restore_handle_from_store(task_id, context) + if handle is None or not self._handle_matches_context(handle, context): + return CancelResult.NOT_RUNNING + try: + if restored: + await self._adapter.attach(handle) + result = await self._adapter.cancel(handle) + except Exception as exc: # noqa: BLE001 + logger.error("A2A runtime cancel failed (%s)", type(exc).__name__) + return CancelResult.FAILED + if result in { + CancelResult.INTERRUPTED_ACTIVE_TURN, + CancelResult.PENDING_CANCEL_RECORDED, + }: + self._accepted_canceled_tasks.add(task_key) + return result + + # ---- input-required → checkpoint/resume token ---- + + def build_resume_target(self, *, invocation_id: str) -> ResumeTarget: + """input-required 的恢复目标(精确到 invocation)。""" + return ResumeTarget(kind="invocation_id", id=invocation_id) + + def build_resume_payload(self, *, call_id: str | None, answer: Any) -> ResumePayload: + """input-required 的回包(HITL 回答 / 审批决定)。""" + return ResumePayload(kind="hitl_answer", call_id=call_id, data=answer) + + async def start_task( + self, + *, + task_id: str, + context: Any, + input_data: Any, + ) -> RunHandle: + """通过 RuntimeAdapter 启动 A2A task,返回后续共用的真实 handle。""" + await self.prepare_context(context) + context_metadata = self._as_dict(getattr(context, "metadata", None)) + context_metadata.pop("user_id", None) + context_metadata.pop("agent_id", None) + tenant = self._trusted_tenant(context) + handle = await self._adapter.start( + StartRequest( + input=input_data, + user_id=tenant, + session_id=self._extract_session_id(context), + agent_id=None, + metadata={**context_metadata, "invocation_id": task_id}, + ) + ) + self._handles_by_task_key[self._task_key(task_id, context)] = handle + return handle + + async def persist_resume_state( + self, + *, + task_id: str, + context: Any, + handle: RunHandle, + checkpoint_id: str | None, + call_id: str | None, + payload_kind: A2AResumePayloadKind, + ) -> None: + """Store input-required recovery state locally, never in A2A wire metadata.""" + if checkpoint_id: + handle.native_ref["checkpoint_id"] = checkpoint_id + known_checkpoint_ids = handle.native_ref.setdefault("known_checkpoint_ids", []) + if checkpoint_id not in known_checkpoint_ids: + known_checkpoint_ids.append(checkpoint_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) + target = ResumeTarget( + kind="checkpoint_id" if checkpoint_id else "invocation_id", + id=str(checkpoint_id or handle.run_id), + ) + await self._resume_state_store.put( + self._resume_key(task_id, context), + A2AResumeState( + handle=handle, + target=target, + payload_kind=payload_kind, + call_id=call_id, + ), + ) + + async def resume_task( + self, + task_id: str, + context: Any, + *, + answer: Any, + ) -> RunHandle: + """Resume from Runtime-local state associated with the protocol Task.""" + await self.prepare_context(context) + handle, target, payload = await self.validate_resume_task( + task_id, + context, + answer=answer, + ) + task_key = self._task_key(task_id, context) + local_handle = self._handles_by_task_key.get(task_key) + if local_handle is None: + attached_handle = await self._adapter.attach(handle) + if attached_handle != handle: + raise ValueError("RuntimeAdapter.attach returned a different run handle") + elif local_handle != handle: + raise ValueError("persisted run_handle conflicts with the active task handle") + resumed_handle = await self._adapter.resume(handle, target, payload) + if resumed_handle != handle: + raise ValueError("RuntimeAdapter.resume returned a different run handle") + self._handles_by_task_key[task_key] = resumed_handle + return resumed_handle + + async def validate_resume_task( + self, + task_id: str, + context: Any, + *, + answer: Any, + ) -> tuple[RunHandle, ResumeTarget, ResumePayload]: + """Validate an A2A resume command without changing task/runtime state.""" + task = getattr(context, "current_task", None) + if str(getattr(task, "id", "") or "") != task_id: + raise ValueError("resume token does not belong to the requested task") + state = await self._resume_state_store.get(self._resume_key(task_id, context)) + if state is None: + raise ValueError("input-required task has no Runtime-local resume state") + handle = state.handle + target = state.target + if target.kind not in {"checkpoint_id", "invocation_id"} or not target.id: + raise ValueError("invalid resume_target in Runtime-local resume state") + payload = self._resume_payload(state, answer=answer) + if not self._handle_matches_context(handle, context): + raise ValueError("run_handle does not match A2A context or task adapter") + return handle, target, payload + + def stream_task(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent]: + """恢复后订阅同一个 RuntimeAdapter 的同一 handle。""" + return self._adapter.stream(handle) + + def was_cancel_accepted(self, task_id: str, context: Any, handle: RunHandle) -> bool: + """Return whether cancel was accepted for this exact runtime handle.""" + task_key = self._task_key(task_id, context) + return ( + self._handles_by_task_key.get(task_key) == handle + and task_key in self._accepted_canceled_tasks + ) + + async def forget_task(self, task_id: str, context: Any, handle: RunHandle) -> None: + """Release process-local tracking after a terminal task state.""" + task_key = self._task_key(task_id, context) + if self._handles_by_task_key.get(task_key) == handle: + self._handles_by_task_key.pop(task_key, None) + self._accepted_canceled_tasks.discard(task_key) + await self.clear_resume_state(task_id, context) + + async def clear_resume_state(self, task_id: str, context: Any) -> None: + """Discard private recovery material while preserving active cancel bookkeeping.""" + + await self._resume_state_store.delete(self._resume_key(task_id, context)) + + @staticmethod + def answer_from_context(context: Any) -> Any: + """优先读取 A2A ``Part.data``,保留 false/0/空串/null。""" + message = getattr(context, "message", None) + for part in getattr(message, "parts", ()) or (): + data = getattr(part, "data", None) + which_oneof = getattr(data, "WhichOneof", None) + if ( + not isinstance(data, ProtobufMessage) + or not callable(which_oneof) + or which_oneof("kind") is None + ): + continue + converted = MessageToDict(data, preserving_proto_field_name=True) + if isinstance(converted, Mapping): + normalized = dict(converted) + if set(normalized) == {"value"}: + return normalized["value"] + return normalized + return converted + return context.get_user_input() + + # ---- metadata 提取 ---- + + @staticmethod + def _extract_session_id(context: Any) -> str: + call_context = getattr(context, "call_context", None) + state = getattr(call_context, "state", None) + if isinstance(state, Mapping): + internal_session_id = str(state.get("a2a_internal_session_id") or "") + if internal_session_id: + return internal_session_id + # A follow-up A2A message may carry only task_id. The durable Task is the + # authority for the original context/session across requests and restarts. + current_task = getattr(context, "current_task", None) + task_context_id = getattr(current_task, "context_id", None) + return str(task_context_id or getattr(context, "context_id", "") or "") + + async def prepare_context(self, context: Any) -> str: + """Resolve the verified external context before adapter state is accessed.""" + + if self._context_store is None: + return self._extract_session_id(context) + call_context = getattr(context, "call_context", None) + state = getattr(call_context, "state", None) + if not isinstance(state, dict): + raise PermissionError("verified Gateway identity is required for A2A context mapping") + cached = str(state.get("a2a_internal_session_id") or "") + if cached: + return cached + identity = state.get("a2a_identity") + if not isinstance(identity, A2AIngressIdentity): + raise PermissionError("verified Gateway identity is required for A2A context mapping") + current_task = getattr(context, "current_task", None) + external_context_id = str( + getattr(current_task, "context_id", "") + or getattr(context, "context_id", "") + or getattr(context, "task_id", "") + or "" + ) + if current_task is not None: + internal_session_id = await self._context_store.get( + identity.context_identity(), + external_context_id, + ) + if internal_session_id is None: + raise RuntimeError("A2A_CONTEXT_MAPPING_NOT_FOUND") + else: + internal_session_id = await self._context_store.resolve_or_create( + identity.context_identity(), + external_context_id, + ) + state["a2a_internal_session_id"] = internal_session_id + return internal_session_id + + @staticmethod + def _trusted_tenant(context: Any) -> str: + call_context = getattr(context, "call_context", None) + return str(getattr(call_context, "tenant", "") or "anonymous") + + def _task_key(self, task_id: str, context: Any) -> tuple[str, str, str]: + return ( + self._trusted_tenant(context), + self._extract_session_id(context), + task_id, + ) + + async def _restore_handle_from_store(self, task_id: str, context: Any) -> RunHandle | None: + task = getattr(context, "current_task", None) + if str(getattr(task, "id", "") or "") != task_id: + return None + state = await self._resume_state_store.get(self._resume_key(task_id, context)) + return state.handle if state is not None else None + + def _handle_matches_context(self, handle: RunHandle, context: Any) -> bool: + return ( + handle.session_id == self._extract_session_id(context) + and handle.runtime_type == self._runtime_type + ) + + def _resume_key(self, task_id: str, context: Any) -> str: + tenant, session_id, normalized_task_id = self._task_key(task_id, context) + return "\x1f".join((tenant, session_id, normalized_task_id)) + + @classmethod + def _resume_payload(cls, state: A2AResumeState, *, answer: Any) -> ResumePayload: + data = ( + cls._approval_decision(answer) + if state.payload_kind == "approval_decision" + else answer + ) + return ResumePayload(kind=state.payload_kind, call_id=state.call_id, data=data) + + @staticmethod + def _approval_decision(answer: Any) -> dict[str, list[dict[str, Any]]]: + if isinstance(answer, Mapping): + if "decisions" in answer: + decisions = answer.get("decisions") + if not isinstance(decisions, list) or not decisions: + raise ValueError("unknown approval decision") + normalized = [dict(item) for item in decisions if isinstance(item, Mapping)] + if len(normalized) != len(decisions): + raise ValueError("unknown approval decision") + if any( + str(item.get("type") or "").strip().lower() not in {"approve", "edit", "reject"} + for item in normalized + ): + raise ValueError("unknown approval decision") + return {"decisions": normalized} + decision = dict(answer) + decision_type = str(decision.get("type") or "").strip().lower() + if decision_type not in {"approve", "edit", "reject"}: + raise ValueError("unknown approval decision") + decision["type"] = decision_type + return {"decisions": [decision]} + token = str(answer).strip().lower() if answer is not None else "" + if token not in {"approve", "edit", "reject"}: + raise ValueError(f"unknown approval decision: {answer!r}") + return {"decisions": [{"type": token}]} + + @staticmethod + def _as_dict(value: Any) -> dict[str, Any]: + descriptor = getattr(value, "DESCRIPTOR", None) + if descriptor is not None: + converted = MessageToDict(value, preserving_proto_field_name=True) + return dict(converted) if isinstance(converted, Mapping) else {} + if isinstance(value, Mapping): + return dict(value) + if value is None: + return {} + return {} + + +__all__ = ["A2ARuntimeTaskAdapter"] diff --git a/ksadk/a2a/task_event_dispatcher.py b/ksadk/a2a/task_event_dispatcher.py new file mode 100644 index 00000000..62bc5cd7 --- /dev/null +++ b/ksadk/a2a/task_event_dispatcher.py @@ -0,0 +1,167 @@ +"""Runtime-shared durable dispatcher for AgentEngine A2A task events.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Protocol + +from ksadk.a2a.task_event_outbox import A2ATaskEventOutbox + +logger = logging.getLogger(__name__) + + +class A2ATaskEventSink(Protocol): + async def append_task_events( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> dict[str, Any]: ... + + +class A2ATaskEventDispatcher: + """One Runtime-owned outbox dispatcher shared by all Space clients.""" + + def __init__( + self, + outbox: A2ATaskEventOutbox, + task_sink: A2ATaskEventSink, + *, + retry_interval_seconds: float = 1.0, + task_sink_timeout_seconds: float = 5.0, + ) -> None: + if retry_interval_seconds <= 0: + raise ValueError("retry_interval_seconds must be positive") + if task_sink_timeout_seconds <= 0: + raise ValueError("task_sink_timeout_seconds must be positive") + self._outbox = outbox + self._task_sink = task_sink + self._retry_interval_seconds = retry_interval_seconds + self._task_sink_timeout_seconds = task_sink_timeout_seconds + self._drain_lock = asyncio.Lock() + self._stop_event = asyncio.Event() + self._wake_event = asyncio.Event() + self._background_task: asyncio.Task[None] | None = None + self._started = False + self._last_error: str | None = None + + @property + def outbox(self) -> A2ATaskEventOutbox: + return self._outbox + + @property + def degraded(self) -> bool: + return self._last_error is not None + + @property + def last_error(self) -> str | None: + return self._last_error + + async def ensure_ready(self) -> None: + await self._outbox.initialize() + + async def ensure_writable(self) -> None: + await self.ensure_ready() + await self._outbox.ensure_writable() + + async def start(self) -> None: + await self.ensure_ready() + await self.ensure_writable() + if self._started: + return + self._started = True + self._stop_event.clear() + self._background_task = asyncio.create_task( + self._run(), + name="ksadk-a2a-task-event-dispatcher", + ) + self._wake_event.set() + + async def stop(self, *, flush_timeout_seconds: float = 5.0) -> None: + if flush_timeout_seconds < 0: + raise ValueError("flush_timeout_seconds cannot be negative") + self._stop_event.set() + task = self._background_task + self._background_task = None + self._started = False + if task is not None: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if flush_timeout_seconds == 0: + return + try: + await asyncio.wait_for( + self.drain(raise_on_error=False), + timeout=flush_timeout_seconds, + ) + except TimeoutError: + logger.warning("A2A task event outbox flush timed out; pending batches were retained") + + async def enqueue( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> str: + await self.ensure_writable() + batch_id = str( + await self._outbox.enqueue( + platform_task_id=platform_task_id, + events=events, + ) + ) + self._wake_event.set() + return batch_id + + async def drain(self, *, raise_on_error: bool) -> int: + await self.ensure_ready() + delivered = 0 + async with self._drain_lock: + while True: + batches = await self._outbox.pending(limit=100) + if not batches: + self._last_error = None + return delivered + for batch in batches: + try: + await asyncio.wait_for( + self._task_sink.append_task_events( + platform_task_id=batch.platform_task_id, + events=batch.events, + ), + timeout=self._task_sink_timeout_seconds, + ) + except Exception as exc: + error = str(getattr(exc, "error_code", "") or type(exc).__name__) + self._last_error = error + await self._outbox.record_failure(batch.batch_id, error) + if raise_on_error: + raise + logger.warning( + "A2A task event batch remains in local outbox: task=%s batch=%s", + batch.platform_task_id, + batch.batch_id, + ) + return delivered + await self._outbox.acknowledge(batch.batch_id) + delivered += 1 + + async def _run(self) -> None: + while not self._stop_event.is_set(): + await self.drain(raise_on_error=False) + try: + await asyncio.wait_for( + self._wake_event.wait(), + timeout=self._retry_interval_seconds, + ) + except TimeoutError: + pass + finally: + self._wake_event.clear() + + +__all__ = ["A2ATaskEventDispatcher", "A2ATaskEventSink"] diff --git a/ksadk/a2a/task_event_outbox.py b/ksadk/a2a/task_event_outbox.py new file mode 100644 index 00000000..ce88a4d6 --- /dev/null +++ b/ksadk/a2a/task_event_outbox.py @@ -0,0 +1,326 @@ +"""Durable local outbox for AgentEngine A2A task event batches.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import sqlite3 +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +ENV_A2A_EVENT_OUTBOX_PATH = "KSADK_A2A_EVENT_OUTBOX_PATH" +DEFAULT_A2A_EVENT_OUTBOX_PATH = ".agentengine/a2a_event_outbox.sqlite3" + + +@dataclass(frozen=True) +class A2ATaskEventBatch: + sequence: int + batch_id: str + platform_task_id: str + events: list[dict[str, Any]] + attempt_count: int = 0 + + +class A2ATaskEventOutbox(ABC): + """Persists task-sink batches until AgentEngine acknowledges them.""" + + @abstractmethod + async def initialize(self) -> None: + raise NotImplementedError + + @abstractmethod + async def ensure_writable(self) -> None: + raise NotImplementedError + + @abstractmethod + async def enqueue( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> str: + raise NotImplementedError + + @abstractmethod + async def pending(self, *, limit: int = 100) -> list[A2ATaskEventBatch]: + raise NotImplementedError + + @abstractmethod + async def acknowledge(self, batch_id: str) -> None: + raise NotImplementedError + + @abstractmethod + async def record_failure(self, batch_id: str, error: str) -> None: + raise NotImplementedError + + +def _serialize_events(events: list[dict[str, Any]]) -> str: + return json.dumps(events, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _batch_id(platform_task_id: str, events_json: str) -> str: + digest = hashlib.sha256(f"{platform_task_id}:{events_json}".encode("utf-8")).hexdigest() + return f"a2a-event-batch-{digest}" + + +class InMemoryA2ATaskEventOutbox(A2ATaskEventOutbox): + """Test/local adapter; production Runtime deployments should use SQLite.""" + + def __init__(self) -> None: + self._batches: dict[str, A2ATaskEventBatch] = {} + self._lock = asyncio.Lock() + self._next_sequence = 0 + + async def initialize(self) -> None: + return None + + async def ensure_writable(self) -> None: + return None + + async def enqueue( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> str: + events_json = _serialize_events(events) + batch_id = _batch_id(platform_task_id, events_json) + async with self._lock: + if batch_id not in self._batches: + self._next_sequence += 1 + self._batches[batch_id] = A2ATaskEventBatch( + sequence=self._next_sequence, + batch_id=batch_id, + platform_task_id=platform_task_id, + events=json.loads(events_json), + ) + return batch_id + + async def pending(self, *, limit: int = 100) -> list[A2ATaskEventBatch]: + async with self._lock: + return sorted(self._batches.values(), key=lambda batch: batch.sequence)[:limit] + + async def acknowledge(self, batch_id: str) -> None: + async with self._lock: + self._batches.pop(batch_id, None) + + async def record_failure(self, batch_id: str, error: str) -> None: + async with self._lock: + batch = self._batches.get(batch_id) + if batch is not None: + self._batches[batch_id] = A2ATaskEventBatch( + sequence=batch.sequence, + batch_id=batch.batch_id, + platform_task_id=batch.platform_task_id, + events=batch.events, + attempt_count=batch.attempt_count + 1, + ) + + +class SQLiteA2ATaskEventOutbox(A2ATaskEventOutbox): + """SQLite adapter with stable batch IDs and crash-safe acknowledgement.""" + + def __init__(self, path: str | os.PathLike[str] | None = None) -> None: + configured = path or os.getenv(ENV_A2A_EVENT_OUTBOX_PATH) or DEFAULT_A2A_EVENT_OUTBOX_PATH + self._path = Path(configured).expanduser() + self._initialized = False + self._init_lock = asyncio.Lock() + + @property + def path(self) -> Path: + return self._path + + async def initialize(self) -> None: + if self._initialized: + return + async with self._init_lock: + if self._initialized: + return + await asyncio.to_thread(self._initialize_sync) + self._initialized = True + + def _initialize_sync(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self._path, timeout=5) + try: + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA synchronous=FULL") + connection.execute( + """ + CREATE TABLE IF NOT EXISTS a2a_task_event_outbox ( + sequence INTEGER NOT NULL, + batch_id TEXT PRIMARY KEY, + platform_task_id TEXT NOT NULL, + events_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT + ) + """ + ) + columns = { + str(row[1]) + for row in connection.execute("PRAGMA table_info(a2a_task_event_outbox)").fetchall() + } + if "sequence" not in columns: + connection.execute("ALTER TABLE a2a_task_event_outbox ADD COLUMN sequence INTEGER") + connection.execute( + "UPDATE a2a_task_event_outbox SET sequence = rowid WHERE sequence IS NULL" + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS a2a_task_event_outbox_sequence ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT + ) + """ + ) + connection.execute( + """ + INSERT OR IGNORE INTO a2a_task_event_outbox_sequence(sequence) + SELECT sequence FROM a2a_task_event_outbox + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS ix_a2a_task_event_outbox_created + ON a2a_task_event_outbox(sequence) + """ + ) + connection.commit() + finally: + connection.close() + os.chmod(self._path, 0o600) + + async def enqueue( + self, + *, + platform_task_id: str, + events: list[dict[str, Any]], + ) -> str: + await self.initialize() + events_json = _serialize_events(events) + batch_id = _batch_id(platform_task_id, events_json) + await asyncio.to_thread( + self._enqueue_sync, + batch_id, + platform_task_id, + events_json, + ) + return batch_id + + async def ensure_writable(self) -> None: + await self.initialize() + await asyncio.to_thread(self._ensure_writable_sync) + + async def pending(self, *, limit: int = 100) -> list[A2ATaskEventBatch]: + await self.initialize() + rows = await asyncio.to_thread(self._pending_sync, limit) + return [ + A2ATaskEventBatch( + sequence=int(row[0]), + batch_id=str(row[1]), + platform_task_id=str(row[2]), + events=list(json.loads(str(row[3]))), + attempt_count=int(row[4]), + ) + for row in rows + ] + + async def acknowledge(self, batch_id: str) -> None: + await self.initialize() + await asyncio.to_thread( + self._execute, + "DELETE FROM a2a_task_event_outbox WHERE batch_id = ?", + (batch_id,), + ) + + async def record_failure(self, batch_id: str, error: str) -> None: + await self.initialize() + await asyncio.to_thread( + self._execute, + """ + UPDATE a2a_task_event_outbox + SET attempt_count = attempt_count + 1, last_error = ? + WHERE batch_id = ? + """, + (error[:512], batch_id), + ) + + def _execute(self, statement: str, params: tuple[Any, ...]) -> None: + connection = sqlite3.connect(self._path, timeout=5) + try: + connection.execute("PRAGMA synchronous=FULL") + connection.execute(statement, params) + connection.commit() + finally: + connection.close() + + def _enqueue_sync(self, batch_id: str, platform_task_id: str, events_json: str) -> None: + connection = sqlite3.connect(self._path, timeout=5, isolation_level=None) + try: + connection.execute("PRAGMA synchronous=FULL") + connection.execute("BEGIN IMMEDIATE") + existing = connection.execute( + "SELECT 1 FROM a2a_task_event_outbox WHERE batch_id = ?", + (batch_id,), + ).fetchone() + if existing is None: + cursor = connection.execute( + "INSERT INTO a2a_task_event_outbox_sequence DEFAULT VALUES" + ) + if cursor.lastrowid is None: + raise RuntimeError("SQLite did not return an outbox sequence") + sequence = int(cursor.lastrowid) + connection.execute( + """ + INSERT INTO a2a_task_event_outbox( + sequence, batch_id, platform_task_id, events_json + ) VALUES (?, ?, ?, ?) + """, + (sequence, batch_id, platform_task_id, events_json), + ) + connection.execute("COMMIT") + except BaseException: + connection.rollback() + raise + finally: + connection.close() + + def _ensure_writable_sync(self) -> None: + connection = sqlite3.connect(self._path, timeout=5, isolation_level=None) + try: + connection.execute("BEGIN IMMEDIATE") + connection.execute("ROLLBACK") + finally: + connection.close() + + def _pending_sync(self, limit: int) -> list[tuple[Any, ...]]: + connection = sqlite3.connect(self._path, timeout=5) + try: + return list( + connection.execute( + """ + SELECT sequence, batch_id, platform_task_id, events_json, attempt_count + FROM a2a_task_event_outbox + ORDER BY sequence + LIMIT ? + """, + (limit,), + ).fetchall() + ) + finally: + connection.close() + + +__all__ = [ + "A2ATaskEventBatch", + "A2ATaskEventOutbox", + "DEFAULT_A2A_EVENT_OUTBOX_PATH", + "ENV_A2A_EVENT_OUTBOX_PATH", + "InMemoryA2ATaskEventOutbox", + "SQLiteA2ATaskEventOutbox", +] diff --git a/ksadk/a2a/task_store.py b/ksadk/a2a/task_store.py new file mode 100644 index 00000000..1038766d --- /dev/null +++ b/ksadk/a2a/task_store.py @@ -0,0 +1,206 @@ +"""A2A durable TaskStore (goal-05)。 + +契约(§4.6):托管被调 task 使用 SDK ``DatabaseTaskStore``,表名隔离为 +``ksadk_a2a_tasks``,owner resolver 必须包含 account/runtime identity。 +**不用 InMemoryTaskStore**——7/31 重启恢复发布门禁依赖 durable store。 + +注意:TaskStore 只持久化**协议 Task**,不等于 runner checkpoint;两者必须共同成功 +才能声明 durable async(§7.2)。SQLite 只用于快速单测;进程崩溃恢复门禁必须使用 +真实 PostgreSQL 和独立 OS 进程。 +""" + +from __future__ import annotations + +import warnings +from threading import Lock +from typing import Any, Callable, Mapping, Optional, Sequence + +from a2a.server import models as _sdk_models +from a2a.server.context import ServerCallContext +from a2a.server.routes.common import DefaultServerCallContextBuilder +from a2a.server.tasks import DatabaseTaskStore +from sqlalchemy.exc import SAWarning +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from starlette.requests import Request + +from ksadk.a2a.identity import A2AIngressIdentity, A2ATrustedIdentityResolver + +#: 契约 §4.6 规定的隔离表名。 +A2A_TASK_TABLE = "ksadk_a2a_tasks" + +DEFAULT_ACCOUNT_HEADERS = ("x-ksc-account-id", "x-account-id") +DEFAULT_RUNTIME_HEADERS = ("x-auth-agent-id", "x-ksc-agent-id", "x-runtime-id") + + +# a2a-sdk 1.1.0 dynamically declares a new ORM model for every custom-table +# store. Repeating the same table in one process raises an SQLAlchemy duplicate +# table error. Cache only KsADK's model instead of monkeypatching the SDK module. +_task_model_cache: dict[str, type[Any]] = {} +_task_model_lock = Lock() + + +def _task_model(table_name: str) -> type[Any]: + with _task_model_lock: + model = _task_model_cache.get(table_name) + if model is None: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=( + "This declarative base already contains a class with the same class name" + ), + category=SAWarning, + ) + model = _sdk_models.create_task_model(table_name) + _task_model_cache[table_name] = model + return model + + +class _KsADKDatabaseTaskStore(DatabaseTaskStore): + """DatabaseTaskStore with a process-local, non-global custom-table model.""" + + def __init__( + self, + *, + engine: AsyncEngine, + create_table: bool, + table_name: str, + owner_resolver: Callable[[ServerCallContext], str], + ) -> None: + super().__init__( + engine=engine, + create_table=create_table, + owner_resolver=owner_resolver, + ) + self.task_model = _task_model(table_name) + + +def _first(state: dict[str, Any], *keys: str) -> Optional[str]: + for key in keys: + value = state.get(key) + if value: + return str(value) + return None + + +def default_owner_resolver(context: ServerCallContext) -> str: + """默认 owner resolver:组合 account + runtime identity(契约 §4.6 要求两者)。 + + owner 形如 ``"{account}/{runtime}"``:account 取 ``account_id``/``tenant_id``, + runtime 取 ``runtime_id``/``agent_id``。**同一 account 下不同 runtime 形成独立任务 + 权限域**(不会共用);只凑齐一侧时用一侧;两侧都缺时退回 ``user``/``anonymous``。 + 生产环境应由鉴权/STS 在 context.state 注入这些键;本地无鉴权时退回匿名。 + """ + state = getattr(context, "state", None) or {} + verified_identity = state.get(A2ATrustedIdentityResolver.state_key) + if isinstance(verified_identity, A2AIngressIdentity): + return verified_identity.owner_key() + account = _first(state, "account_id", "tenant_id") + runtime = _first(state, "runtime_id", "agent_id") + parts = [part for part in (account, runtime) if part] + if parts: + return "/".join(parts) + return _first(state, "user") or "anonymous" + + +class A2AOwnerContextBuilder(DefaultServerCallContextBuilder): + """Build A2A call contexts from platform-authenticated HTTP identity. + + The SDK default stores headers only under ``state['headers']`` while the + task store intentionally reads normalized top-level identity fields. This + builder is shared by JSON-RPC and REST so both transports enforce the same + account/runtime owner boundary. Deployments must strip/replace these + platform headers at the public edge; message metadata is never trusted. + """ + + def __init__( + self, + *, + account_headers: Sequence[str] = DEFAULT_ACCOUNT_HEADERS, + runtime_headers: Sequence[str] = DEFAULT_RUNTIME_HEADERS, + identity_resolver: A2ATrustedIdentityResolver | None = None, + allow_unverified_identity: bool = True, + ) -> None: + self._account_headers = tuple(header.lower() for header in account_headers) + self._runtime_headers = tuple(header.lower() for header in runtime_headers) + self._identity_resolver = identity_resolver + self._allow_unverified_identity = allow_unverified_identity + + @staticmethod + def _header(headers: Mapping[str, Any], names: Sequence[str]) -> str | None: + for name in names: + value = str(headers.get(name) or "").strip() + if value: + return value + return None + + def build(self, request: Request) -> ServerCallContext: + context = super().build(request) + state = dict(context.state or {}) + headers = {str(key).lower(): value for key, value in request.headers.items()} + scope_state = request.scope.get("state") + trusted_state = dict(scope_state) if isinstance(scope_state, Mapping) else {} + if self._identity_resolver is not None: + identity = self._identity_resolver.resolve(request) + state[A2ATrustedIdentityResolver.state_key] = identity + state["account_id"] = identity.account_id + state["tenant_id"] = identity.tenant_id + state["caller_principal_type"] = identity.caller_principal_type + state["caller_principal_id"] = identity.caller_principal_id + elif not self._allow_unverified_identity: + raise PermissionError("verified Gateway identity is required for inbound A2A") + else: + account = _first(trusted_state, "account_id", "tenant_id") or self._header( + headers, self._account_headers + ) + runtime = _first(trusted_state, "runtime_id", "agent_id") or self._header( + headers, self._runtime_headers + ) + if account: + state["account_id"] = account + if runtime: + state["runtime_id"] = runtime + context.state = state + context.tenant = default_owner_resolver(context) + return context + + +def build_a2a_task_store( + dsn: str | None = None, + *, + engine: Optional[AsyncEngine] = None, + table_name: str = A2A_TASK_TABLE, + owner_resolver: Optional[Callable[[ServerCallContext], str]] = None, + create_table: bool = True, +) -> DatabaseTaskStore: + """构建 durable ``DatabaseTaskStore``。 + + 参数: + dsn: 数据库 DSN(如 ``sqlite+aiosqlite:///path.db`` 或 + ``postgresql+asyncpg://...``)。与 ``engine`` 二选一。 + engine: 已存在的 SQLAlchemy AsyncEngine(优先于 dsn)。 + table_name: 隔离表名,默认 ``ksadk_a2a_tasks``。 + owner_resolver: owner 解析器,默认 :func:`default_owner_resolver`。 + create_table: 是否在 initialize 时建表(测试/本地默认 True;生产可 False + 由迁移管理)。 + """ + if engine is None: + if not dsn: + raise ValueError("build_a2a_task_store 需要 dsn 或 engine 之一") + engine = create_async_engine(dsn) + return _KsADKDatabaseTaskStore( + engine=engine, + create_table=create_table, + table_name=table_name, + owner_resolver=owner_resolver or default_owner_resolver, + ) + + +__all__ = [ + "A2A_TASK_TABLE", + "A2AOwnerContextBuilder", + "DEFAULT_ACCOUNT_HEADERS", + "DEFAULT_RUNTIME_HEADERS", + "build_a2a_task_store", + "default_owner_resolver", +] diff --git a/ksadk/a2ui/__init__.py b/ksadk/a2ui/__init__.py new file mode 100644 index 00000000..cbe3068e --- /dev/null +++ b/ksadk/a2ui/__init__.py @@ -0,0 +1,27 @@ +"""A2UI(Agent 驱动 UI)— canonical:docs/A2UI-agent驱动UI技术方案.md。""" + +from ksadk.a2ui.core import A2UICore +from ksadk.a2ui.models import ( + BASIC_CATALOG, + BASIC_CATALOG_ID, + A2UIValidationError, + ActionReceipt, + Component, + PendingInteraction, + Surface, +) +from ksadk.a2ui.renderer import A2UIRenderer, RenderedNode, submit_a2ui_action + +__all__ = [ + "A2UICore", + "A2UIRenderer", + "RenderedNode", + "submit_a2ui_action", + "A2UIValidationError", + "ActionReceipt", + "BASIC_CATALOG", + "BASIC_CATALOG_ID", + "Component", + "PendingInteraction", + "Surface", +] diff --git a/ksadk/a2ui/core.py b/ksadk/a2ui/core.py new file mode 100644 index 00000000..57501bfc --- /dev/null +++ b/ksadk/a2ui/core.py @@ -0,0 +1,186 @@ +"""A2UICore — Agent-facing interface + RuntimeEvent 产出 (goal-13,canonical §5.1/§5.2)。 + +**硬约束(canonical §1 / H2 P0-C):A2UI 必须先进入 RuntimeEvent,不得以 A2A executor +绕过**——本类的 ``display_ui`` / ``request_ui_input`` / ``submit_action`` 全部经 A7 +``RuntimeEventStore`` 产出并持久化 A2UI 事件(``a2ui.surface.*`` / ``a2ui.interaction`` / +``a2ui.action``),由 session 级订阅承载(A2UI 依赖,可 run 后非阻塞 action 与回放)。 + +冻结 schema 映射(G0.2 已冻结,不改):surface 首显 ``a2ui.surface.begin`` / 更新 +``a2ui.surface.update`` / 结束 ``a2ui.surface.end``;请求输入 ``a2ui.interaction``; +action ``a2ui.action``。均带 ``surface_id``(G0.2 必填键)。 +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any, Optional + +from ksadk.a2ui.models import ( + BASIC_CATALOG, + ActionReceipt, + PendingInteraction, + Surface, +) +from ksadk.events.runtime_event import EventType, RuntimeEvent +from ksadk.events.store import RuntimeEventStore + +logger = logging.getLogger(__name__) + + +class A2UICore: + """A2UI Agent-facing interface:display / request_input / submit_action。 + + 所有 A2UI 事件经 :class:`RuntimeEventStore` 持久化(**不另开通道、不经 A2A 绕过**), + 供 session 级订阅 / replay / 审计消费。 + """ + + def __init__( + self, + store: RuntimeEventStore, + *, + agent_id: str, + user_id: str, + session_id: str, + catalog: dict[str, frozenset[str]] = BASIC_CATALOG, + ) -> None: + self._store = store + self._agent_id = agent_id + self._user_id = user_id + self._session_id = session_id + self._catalog = catalog + self._seq = 0 + self._seen_surfaces: set[str] = set() + self._pending: dict[str, PendingInteraction] = {} + + # ---- 内部:产出并持久化一个 A2UI RuntimeEvent ---- + + async def _emit( + self, event_type: str, invocation_id: str, payload: dict[str, Any] + ) -> RuntimeEvent: + self._seq += 1 + event = RuntimeEvent.create( + event_type, + agent_id=self._agent_id, + user_id=self._user_id, + session_id=self._session_id, + invocation_id=invocation_id, + seq_id=self._seq, + payload=payload, + ) + # 经 RuntimeEvent 持久化(A7 store)——canonical,不绕过。 + await self._store.append_one(event) + return event + + # ---- 三种交互 ---- + + async def display_ui( + self, + surface: Surface, + *, + invocation_id: str, + origin: str = "local", + ) -> str: + """展示 surface(不阻塞)。首显发 surface.begin,重复显发 surface.update。""" + surface.validate(self._catalog) + event_type = ( + EventType.A2UI_SURFACE_BEGIN + if surface.surface_id not in self._seen_surfaces + else EventType.A2UI_SURFACE_UPDATE + ) + self._seen_surfaces.add(surface.surface_id) + await self._emit( + event_type, + invocation_id, + { + "surface_id": surface.surface_id, + "catalog_id": surface.catalog_id, + "surface": surface.to_dict(), + "origin": origin, + }, + ) + return surface.surface_id + + async def request_ui_input( + self, + surface: Surface, + *, + schema: dict[str, Any], + kind: str, + invocation_id: str, + origin: str = "local", + ) -> PendingInteraction: + """等待用户输入(input_required 语义):先展示 surface,再登记 pending interaction。 + + 返回的 :class:`PendingInteraction` 是一次等待输入的持久化身份;用户上行 + ``SubmitA2UIAction`` 命中后 resume(由 RuntimeBridge/adapter 处理,不在本层)。 + """ + # 先展示(确保 surface 已渲染),再请求输入。 + await self.display_ui(surface, invocation_id=invocation_id, origin=origin) + interaction = PendingInteraction( + interaction_id=f"int_{uuid.uuid4().hex[:12]}", + surface_id=surface.surface_id, + kind=kind, + input_schema=dict(schema), + ) + self._pending[interaction.interaction_id] = interaction + await self._emit( + EventType.A2UI_INTERACTION, + invocation_id, + { + "surface_id": surface.surface_id, + "interaction_id": interaction.interaction_id, + "kind": kind, + "input_schema": dict(schema), + }, + ) + return interaction + + async def submit_action( + self, + action: dict[str, Any], + *, + invocation_id: str, + origin: str = "local", + ) -> ActionReceipt: + """非阻塞 action(原 run 可已结束):登记 action.received,返回幂等回执。 + + ``action``: ``{"action_id","surface_id","name","actor"?,"component_id"?}``。 + """ + receipt = ActionReceipt( + action_id=str(action.get("action_id") or f"act_{uuid.uuid4().hex[:12]}"), + surface_id=str(action["surface_id"]), + name=str(action["name"]), + actor=str(action.get("actor") or "user"), + status="received", + ) + await self._emit( + EventType.A2UI_ACTION, + invocation_id, + { + "surface_id": receipt.surface_id, + "action_id": receipt.action_id, + "name": receipt.name, + "actor": receipt.actor, + "component_id": action.get("component_id"), + "origin": origin, + }, + ) + return receipt + + async def end_surface(self, surface_id: str, *, invocation_id: str) -> None: + """结束 surface(surface.end)。""" + await self._emit( + EventType.A2UI_SURFACE_END, + invocation_id, + {"surface_id": surface_id}, + ) + self._seen_surfaces.discard(surface_id) + + # ---- 查询 ---- + + def pending_interaction(self, interaction_id: str) -> Optional[PendingInteraction]: + return self._pending.get(interaction_id) + + +__all__ = ["A2UICore"] diff --git a/ksadk/a2ui/models.py b/ksadk/a2ui/models.py new file mode 100644 index 00000000..b399f875 --- /dev/null +++ b/ksadk/a2ui/models.py @@ -0,0 +1,166 @@ +"""A2UI 语义模型 + basic catalog (goal-13,canonical 见 docs/A2UI-agent驱动UI技术方案.md)。 + +模型独立于 transport(RuntimeEvent 是 canonical;Responses/A2A 只是 adapter): + +- ``Component`` / ``Surface``:声明式组件树 + 数据模型(props 是**数据**,不含可执行代码)。 +- ``PendingInteraction``:一次 ``request_ui_input`` 的持久化身份(input_required 语义)。 +- ``ActionReceipt``:一次 action 的幂等回执。 +- ``BASIC_CATALOG``:Q3 MVP 渲染集(Card/Text/Form/Select/RadioGroup/CheckboxGroup/ + ApprovalBar)。**未知组件类型安全降级**(渲染为占位,不执行任意代码),符合设计 §3.1/§3.2。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Any, Optional + +#: pinned A2UI core 版本(goal-13 版本约束:a2ui-core==0.1.1,A2UI v1.0 Candidate)。 +#: 校验期据此断言构建期 pinned 版本未漂移(Q4 治理:upstream commit + schema/catalog SHA)。 +try: + from a2ui.core.version import __version__ as A2UI_CORE_VERSION +except ImportError: # pragma: no cover - a2ui-core 未安装时显式降级 + A2UI_CORE_VERSION = "uninstalled" + +# --------------------------------------------------------------------------- +# basic catalog(Q3 MVP 渲染集;未知类型安全降级) +# --------------------------------------------------------------------------- + +#: 允许展示/交互的组件类型及其允许的 prop 键(白名单;props 是数据,不允许代码)。 +BASIC_CATALOG: dict[str, frozenset[str]] = { + "Card": frozenset({"title", "body", "footer", "children"}), + "Text": frozenset({"text", "variant"}), + "Form": frozenset({"title", "fields", "submit_label"}), + "Select": frozenset({"label", "options", "value"}), + "RadioGroup": frozenset({"label", "options", "value"}), + "CheckboxGroup": frozenset({"label", "options", "value"}), + "ApprovalBar": frozenset({"tool_name", "summary", "approve_label", "deny_label"}), +} + +#: catalog 标识(对应 pinned basic catalog;构建期校验 SHA 是后续治理项)。 +BASIC_CATALOG_ID = "a2ui.org/catalogs/basic/v1.0" + + +class A2UIValidationError(ValueError): + """surface/component 校验失败(未知类型、非法 prop、结构错误)。""" + + +@dataclass +class Component: + """声明式组件(props 是数据,不可执行)。""" + + component_id: str + type: str + props: dict[str, Any] = field(default_factory=dict) + children: list["Component"] = field(default_factory=list) + + def validate(self, catalog: dict[str, frozenset[str]] = BASIC_CATALOG) -> None: + """校验组件类型已知 + prop 在白名单 + 无嵌套代码字段。未知类型 → 安全降级错误。""" + if self.type not in catalog: + raise A2UIValidationError( + f"未知组件类型 {self.type!r}(不在 basic catalog;安全降级,不渲染任意代码)" + ) + allowed = catalog[self.type] + unknown_props = set(self.props.keys()) - allowed - {"children"} + if unknown_props: + raise A2UIValidationError(f"组件 {self.type} 含白名单外 prop: {sorted(unknown_props)}") + for key, value in self.props.items(): + if callable(value): + raise A2UIValidationError(f"组件 {self.type} prop {key} 不可为可执行代码") + for child in self.children: + child.validate(catalog) + + def to_dict(self) -> dict[str, Any]: + return { + "component_id": self.component_id, + "type": self.type, + "props": self.props, + "children": [c.to_dict() for c in self.children], + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Component": + """从 dict(a2ui.surface.* 事件 payload 中的组件)重建;供 renderer 反序列化。""" + return cls( + component_id=str(data.get("component_id") or ""), + type=str(data.get("type") or ""), + props=dict(data.get("props") or {}), + children=[cls.from_dict(c) for c in data.get("children") or []], + ) + + +@dataclass +class Surface: + """一个 UI surface(组件树 + 数据模型)。""" + + surface_id: str + components: list[Component] = field(default_factory=list) + data_model: dict[str, Any] = field(default_factory=dict) + catalog_id: str = BASIC_CATALOG_ID + + @classmethod + def new(cls, components: list[Component], *, data_model: Optional[dict] = None) -> "Surface": + return cls( + surface_id=f"surf_{uuid.uuid4().hex[:12]}", + components=components, + data_model=data_model or {}, + ) + + def validate(self, catalog: dict[str, frozenset[str]] = BASIC_CATALOG) -> None: + if not self.surface_id: + raise A2UIValidationError("surface_id 不能为空") + for component in self.components: + component.validate(catalog) + + def to_dict(self) -> dict[str, Any]: + return { + "surface_id": self.surface_id, + "catalog_id": self.catalog_id, + "components": [c.to_dict() for c in self.components], + "data_model": self.data_model, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Surface": + """从 dict(a2ui.surface.* 事件 payload["surface"])重建 Surface;供 renderer 反序列化。""" + return cls( + surface_id=str(data.get("surface_id") or ""), + components=[Component.from_dict(c) for c in data.get("components") or []], + data_model=dict(data.get("data_model") or {}), + catalog_id=str(data.get("catalog_id") or BASIC_CATALOG_ID), + ) + + +@dataclass +class PendingInteraction: + """一次 request_ui_input 的持久化身份(input_required;命中后 resume)。""" + + interaction_id: str + surface_id: str + kind: str # "form" | "select" | "approval" | ... + input_schema: dict[str, Any] = field(default_factory=dict) + status: str = "pending" # "pending" | "resolved" | "expired" + + +@dataclass +class ActionReceipt: + """一次 action 的幂等回执(审计用)。""" + + action_id: str + surface_id: str + name: str + actor: str = "user" + status: str = "received" # "received" | "completed" | "failed" + response: Optional[dict[str, Any]] = None + error: Optional[str] = None + + +__all__ = [ + "A2UIValidationError", + "ActionReceipt", + "BASIC_CATALOG", + "BASIC_CATALOG_ID", + "Component", + "PendingInteraction", + "Surface", +] diff --git a/ksadk/a2ui/renderer.py b/ksadk/a2ui/renderer.py new file mode 100644 index 00000000..eacd5f2e --- /dev/null +++ b/ksadk/a2ui/renderer.py @@ -0,0 +1,198 @@ +"""A2UI renderer registry + 流式渲染 + SubmitA2UIAction 回传 (goal-14,canonical §5.4)。 + +- **registry**:Card / Text / Form / Select / RadioGroup / CheckboxGroup / ApprovalBar 渲染; + **未知 catalog/组件类型安全降级**为 ``placeholder``,**不执行任何动态代码**(安全约束)。 +- **Approval 是硬要求**(goal-18 预发演示的人机交互):``ApprovalBar`` 渲染为可交互审批卡, + 带 approve/deny 动作,经 :func:`submit_a2ui_action`(SubmitA2UIAction)回传到 + :class:`~ksadk.a2ui.core.A2UICore.submit_action`,形成"渲染→人点选→回传→resume"闭环。 +- **parser 复用 goal-12 共享实现,不在 renderer 复制**(停止条件):surface 状态经 + goal-13 ``Surface.from_dict`` 从 a2ui.surface.* 事件重建,renderer 只负责渲染。 +- **live/replay 状态一致**:同一 surface 事件流,live 渲染与 replay 渲染产物逐字节一致 + (``to_json`` 确定性);交互记录(a2ui.action)也经 goal-12 replay 可回放。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +from ksadk.a2ui.models import BASIC_CATALOG, Component, Surface + +# --------------------------------------------------------------------------- +# RenderedNode:渲染产物(确定性,可 to_json 供 conformance) +# --------------------------------------------------------------------------- + + +@dataclass +class RenderedNode: + """一个渲染节点(组件渲染结果;``actions`` 承载可交互动作,如审批卡的 approve/deny)。""" + + type: str + props: dict[str, Any] = field(default_factory=dict) + children: list["RenderedNode"] = field(default_factory=list) + actions: list[dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.type, + "props": self.props, + "actions": self.actions, + "children": [c.to_dict() for c in self.children], + } + + def to_json(self) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +# --------------------------------------------------------------------------- +# 各类型 renderer(纯函数,无动态代码) +# --------------------------------------------------------------------------- + + +def _render_text(comp: Component, _r: "A2UIRenderer") -> RenderedNode: + return RenderedNode( + type="text", + props={"text": str(comp.props.get("text") or ""), "variant": comp.props.get("variant")}, + ) + + +def _render_card(comp: Component, r: "A2UIRenderer") -> RenderedNode: + return RenderedNode( + type="card", + props={ + "title": comp.props.get("title"), + "body": comp.props.get("body"), + "footer": comp.props.get("footer"), + }, + children=[r.render_component(c) for c in comp.children], + ) + + +def _render_form(comp: Component, r: "A2UIRenderer") -> RenderedNode: + return RenderedNode( + type="form", + props={ + "title": comp.props.get("title"), + "fields": comp.props.get("fields") or [], + "submit_label": comp.props.get("submit_label") or "提交", + }, + actions=[{"name": "submit", "label": comp.props.get("submit_label") or "提交"}], + children=[r.render_component(c) for c in comp.children], + ) + + +def _render_choice(kind: str) -> Callable[[Component, "A2UIRenderer"], RenderedNode]: + def _render(comp: Component, _r: "A2UIRenderer") -> RenderedNode: + return RenderedNode( + type=kind, + props={ + "label": comp.props.get("label"), + "options": comp.props.get("options") or [], + "value": comp.props.get("value"), + }, + actions=[{"name": "change", "label": comp.props.get("label")}], + ) + + return _render + + +def _render_approval(comp: Component, _r: "A2UIRenderer") -> RenderedNode: + """ApprovalBar → 可交互审批卡(approve/deny 动作;硬要求,goal-18 人机交互用)。""" + return RenderedNode( + type="approval", + props={ + "tool_name": comp.props.get("tool_name"), + "summary": comp.props.get("summary"), + }, + actions=[ + {"name": "approve", "label": comp.props.get("approve_label") or "批准"}, + {"name": "deny", "label": comp.props.get("deny_label") or "拒绝"}, + ], + ) + + +def _render_placeholder(comp: Component, _r: "A2UIRenderer") -> RenderedNode: + """未知类型安全降级:渲染为占位,**不执行任何动态代码**(props 不回显为可执行内容)。""" + return RenderedNode( + type="placeholder", + props={"reason": f"未知组件类型 {comp.type!r},已安全降级"}, + ) + + +# --------------------------------------------------------------------------- +# A2UIRenderer registry +# --------------------------------------------------------------------------- + + +class A2UIRenderer: + """A2UI renderer registry(类型 → renderer;未知类型安全降级,不执行动态代码)。""" + + def __init__(self, catalog: dict[str, frozenset[str]] = BASIC_CATALOG) -> None: + self._catalog = catalog + self._renderers: dict[str, Callable[[Component, "A2UIRenderer"], RenderedNode]] = { + "Card": _render_card, + "Text": _render_text, + "Form": _render_form, + "Select": _render_choice("select"), + "RadioGroup": _render_choice("radio_group"), + "CheckboxGroup": _render_choice("checkbox_group"), + "ApprovalBar": _render_approval, + } + + def register( + self, component_type: str, renderer: Callable[[Component, "A2UIRenderer"], RenderedNode] + ) -> None: + """注册自定义 renderer(扩展 catalog 用;renderer 必须是纯函数,不含动态代码)。""" + self._renderers[component_type] = renderer + + def render_component(self, component: Component) -> RenderedNode: + renderer = self._renderers.get(component.type, _render_placeholder) + return renderer(component, self) + + def render_surface(self, surface: Surface) -> RenderedNode: + """渲染一个 surface(流式:组件树递归渲染;产物确定性,供 live/replay conformance)。""" + return RenderedNode( + type="surface", + props={"surface_id": surface.surface_id, "catalog_id": surface.catalog_id}, + children=[self.render_component(c) for c in surface.components], + ) + + +# --------------------------------------------------------------------------- +# SubmitA2UIAction 回传(渲染 → 人点选 → 回传 → resume) +# --------------------------------------------------------------------------- + + +async def submit_a2ui_action( + core: Any, + *, + action_id: str, + surface_id: str, + name: str, + actor: str = "user", + component_id: Optional[str] = None, + invocation_id: str, +) -> Any: + """SubmitA2UIAction:把用户对渲染卡(如审批卡 approve/deny)的点选回传到 A2UICore。 + + 与 :class:`~ksadk.a2ui.core.A2UICore.submit_action` 接口对齐(不在 renderer 侧自造语义): + 产出 ``a2ui.action`` 事件(经 RuntimeEvent 持久化),供 resume / 审计 / 回放。 + """ + return await core.submit_action( + { + "action_id": action_id, + "surface_id": surface_id, + "name": name, + "actor": actor, + "component_id": component_id, + }, + invocation_id=invocation_id, + ) + + +__all__ = [ + "A2UIRenderer", + "RenderedNode", + "submit_a2ui_action", +] diff --git a/ksadk/agui/__init__.py b/ksadk/agui/__init__.py new file mode 100644 index 00000000..868c68ab --- /dev/null +++ b/ksadk/agui/__init__.py @@ -0,0 +1,9 @@ +"""Official AG-UI transport integration for KSADK runtimes.""" + +from ksadk.agui.config import ( + AGUI_PROTOCOL_VERSION, + AGUIConfig, + agui_dependencies_available, +) + +__all__ = ["AGUIConfig", "AGUI_PROTOCOL_VERSION", "agui_dependencies_available"] diff --git a/ksadk/agui/a2ui_projection.py b/ksadk/agui/a2ui_projection.py new file mode 100644 index 00000000..9dc6f7e5 --- /dev/null +++ b/ksadk/agui/a2ui_projection.py @@ -0,0 +1,92 @@ +"""Project canonical A2UI RuntimeEvents into official AG-UI activity content.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ksadk.events.runtime_event import EventType + + +def project_a2ui_operations(event_type: str, payload: Mapping[str, Any]) -> list[dict[str, Any]]: + """Return A2UI v0.9 operations carried by an AG-UI activity event.""" + + explicit = payload.get("operations", payload.get("a2ui_operations")) + if isinstance(explicit, list): + return [dict(operation) for operation in explicit if isinstance(operation, Mapping)] + + surface_id = str(payload.get("surface_id") or payload.get("surfaceId") or "") + if not surface_id: + return [] + if event_type == EventType.A2UI_SURFACE_END: + return [{"version": "v0.9", "deleteSurface": {"surfaceId": surface_id}}] + + surface = payload.get("surface") + surface_data = surface if isinstance(surface, Mapping) else payload + components = _flatten_components(surface_data.get("components")) + data_model = surface_data.get("data_model", surface_data.get("dataModel")) + operations: list[dict[str, Any]] = [] + if event_type == EventType.A2UI_SURFACE_BEGIN: + catalog_id = str( + surface_data.get("catalog_id") + or surface_data.get("catalogId") + or payload.get("catalog_id") + or payload.get("catalogId") + or "https://a2ui.org/specification/v0_9/basic_catalog.json" + ) + operations.append( + { + "version": "v0.9", + "createSurface": {"surfaceId": surface_id, "catalogId": catalog_id}, + } + ) + if components: + operations.append( + { + "version": "v0.9", + "updateComponents": {"surfaceId": surface_id, "components": components}, + } + ) + if data_model is not None: + operations.append( + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": surface_id, + "path": "/", + "value": data_model, + }, + } + ) + return operations + + +def _flatten_components(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + flattened: list[dict[str, Any]] = [] + + def visit(component: Any) -> str: + if not isinstance(component, Mapping): + return "" + component_id = str(component.get("id") or component.get("component_id") or "") + component_type = str(component.get("component") or component.get("type") or "") + if not component_id or not component_type: + return "" + children = [visit(child) for child in component.get("children") or []] + children = [child_id for child_id in children if child_id] + entry: dict[str, Any] = {"id": component_id, "component": component_type} + props = component.get("props") + if isinstance(props, Mapping): + entry.update(dict(props)) + if children and "children" not in entry and "child" not in entry: + entry["children"] = children + flattened.append(entry) + return component_id + + for item in raw: + visit(item) + return flattened + + +__all__ = ["project_a2ui_operations"] diff --git a/ksadk/agui/agent.py b/ksadk/agui/agent.py new file mode 100644 index 00000000..bd143b61 --- /dev/null +++ b/ksadk/agui/agent.py @@ -0,0 +1,857 @@ +"""AG-UI Agent duck seam backed exclusively by a KSADK RuntimeAdapter.""" + +from __future__ import annotations + +import asyncio +import copy +import hashlib +import json +import logging +from dataclasses import dataclass, field +from importlib import import_module +from typing import Any, AsyncIterator, Callable, Mapping, Optional, cast + +from ag_ui.core import ( + ActivitySnapshotEvent, + BaseEvent, + Interrupt, + ReasoningEndEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + RunAgentInput, + RunErrorEvent, + RunFinishedEvent, + RunFinishedInterruptOutcome, + RunFinishedSuccessOutcome, + RunStartedEvent, + StateSnapshotEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) + +from ksadk.agui.a2ui_projection import project_a2ui_operations +from ksadk.conversations.runtime_metadata import ( + _update_session_metadata_after_assistant_turn, + prime_session_metadata_for_user_turn, +) +from ksadk.events.runtime_event import EventType, RuntimeEvent +from ksadk.runtime.adapter import ( + CancelResult, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeAdapter, + StartRequest, +) + +EventStoreFactory = Callable[[], Any] +SessionServiceFactory = Callable[[], Any] +logger = logging.getLogger(__name__) + + +def _split_a2ui_schema_context(context: list[Any]) -> tuple[Any, list[Any]]: + """Call the optional toolkit without requiring its package to ship stubs.""" + module = import_module("ag_ui_a2ui_toolkit") + splitter = getattr(module, "split_a2ui_schema_context", None) + if not callable(splitter): + raise RuntimeError("ag-ui-a2ui-toolkit does not expose split_a2ui_schema_context") + result = splitter(context) + if not isinstance(result, tuple) or len(result) != 2 or not isinstance(result[1], list): + raise RuntimeError("ag-ui-a2ui-toolkit returned an invalid schema context split") + return result[0], result[1] + + +@dataclass +class _PendingInterrupt: + interrupt: Interrupt + checkpoint_id: str + + +@dataclass +class _ThreadRun: + handle: RunHandle + active: bool = False + interrupted: bool = False + cancel_failed: bool = False + pending: dict[str, _PendingInterrupt] = field(default_factory=dict) + + +@dataclass +class _SharedRuntime: + adapter: RuntimeAdapter + event_store_factory: Optional[EventStoreFactory] + session_service_factory: Optional[SessionServiceFactory] + threads: dict[str, _ThreadRun] = field(default_factory=dict) + consumed_resumes: dict[tuple[str, str], Any] = field(default_factory=dict) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +@dataclass +class _WireState: + thread_id: str + run_id: str + message_id: str + reasoning_id: str + text_open: bool = False + reasoning_open: bool = False + terminal: bool = False + text_content: str = "" + + +class KsadkAGUIAgent: + """Implements the endpoint helper's pinned ``name/clone/run`` seam. + + The official ``ag-ui-langgraph==0.0.42`` helper clones this object per HTTP + request and encodes the official event models yielded by :meth:`run`. The + shared state contains only the app-owned RuntimeAdapter and handle registry; + no native graph or runner-private attribute is exposed here. + """ + + def __init__( + self, + *, + name: str, + adapter: RuntimeAdapter, + 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, + ) + + def clone(self) -> "KsadkAGUIAgent": + return type(self)(name=self.name, adapter=self._shared.adapter, _shared=self._shared) + + async def run(self, input: RunAgentInput) -> AsyncIterator[BaseEvent]: + wire = _WireState( + thread_id=input.thread_id, + run_id=input.run_id, + message_id=f"{input.run_id}:assistant", + reasoning_id=f"{input.run_id}:reasoning", + ) + yield RunStartedEvent( + thread_id=input.thread_id, + run_id=input.run_id, + parent_run_id=input.parent_run_id, + input=input, + ) + + run: Optional[_ThreadRun] = None + try: + run, duplicate = await self._resolve_run(input) + if duplicate: + yield RunFinishedEvent( + thread_id=input.thread_id, + run_id=input.run_id, + outcome=RunFinishedSuccessOutcome(), + result={"status": "already_resumed"}, + ) + return + + run.active = True + async for runtime_event in self._shared.adapter.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 + if not wire.terminal: + async for event in self._finish_success(wire): + yield event + if not run.interrupted: + await self._update_session_metadata_after_assistant_turn(input, wire) + await self._close_thread(input.thread_id, run) + except (asyncio.CancelledError, GeneratorExit): + if run is not None: + await self._cancel_and_close(input.thread_id, run) + raise + except Exception: + logger.exception( + "AG-UI runtime execution failed for thread=%s run=%s", + input.thread_id, + input.run_id, + ) + if run is not None: + await self._close_thread(input.thread_id, run) + if not wire.terminal: + async for event in self._close_open_messages(wire): + yield event + yield RunErrorEvent( + message="Runtime execution failed", + code="RUNTIME_ERROR", + ) + finally: + if run is not None and not run.cancel_failed: + run.active = False + + async def _resolve_run(self, input: RunAgentInput) -> tuple[_ThreadRun, bool]: + async with self._shared.lock: + if input.resume: + return await self._resume_locked(input) + + current = self._shared.threads.get(input.thread_id) + if current is not None and (current.active or current.interrupted): + raise ValueError(f"thread {input.thread_id!r} already has an active run") + + forwarded = input.forwarded_props if isinstance(input.forwarded_props, dict) else {} + tools = [tool.model_dump(by_alias=True) for tool in input.tools] + a2ui_schema, regular_context = _split_a2ui_schema_context(input.context) + ag_ui_state: dict[str, Any] = { + "tools": tools, + "context": [item.model_dump(by_alias=True) for item in regular_context], + } + if a2ui_schema is not None: + ag_ui_state["a2ui_schema"] = a2ui_schema + inject_a2ui_tool = forwarded.get("injectA2UITool", forwarded.get("inject_a2ui_tool")) + if inject_a2ui_tool is not None: + ag_ui_state["inject_a2ui_tool"] = inject_a2ui_tool + request_config = dict(input.state) if isinstance(input.state, dict) else {} + request_config.update( + { + "ag-ui": ag_ui_state, + "copilotkit": {"actions": tools}, + } + ) + request = StartRequest( + input=self._latest_user_input(input), + user_id=str(forwarded.get("userId") or forwarded.get("user_id") or "agui-user"), + session_id=input.thread_id, + agent_id=self.name, + model=str(forwarded.get("model") or "") or None, + config=request_config, + metadata={"invocation_id": input.run_id, "transport": "ag-ui"}, + ) + handle = await self._shared.adapter.start(request) + await self._persist_user_input(input, request, handle) + run = _ThreadRun(handle=handle) + self._shared.threads[input.thread_id] = run + return run, False + + async def _resume_locked(self, input: RunAgentInput) -> tuple[_ThreadRun, bool]: + entries = input.resume or [] + ids = [entry.interrupt_id for entry in entries] + if len(ids) != len(set(ids)): + raise ValueError("resume contains duplicate interruptId values") + + current = self._shared.threads.get(input.thread_id) + if current is None or not current.interrupted: + consumed_fingerprints = { + entry.interrupt_id: self._resume_fingerprint(entry.status, entry.payload) + for entry in entries + } + if consumed_fingerprints and all( + self._shared.consumed_resumes.get((input.thread_id, interrupt_id)) == fingerprint + for interrupt_id, fingerprint in consumed_fingerprints.items() + ): + return self._duplicate_run(input), True + current, duplicate = await self._restore_durable_run(input, consumed_fingerprints) + if duplicate: + return self._duplicate_run(input), True + if current is None: + raise ValueError(f"thread {input.thread_id!r} has no resumable run") + self._shared.threads[input.thread_id] = current + + pending_ids = set(current.pending) + if set(ids) != pending_ids: + missing = sorted(pending_ids - set(ids)) + unknown = sorted(set(ids) - pending_ids) + raise ValueError( + f"resume does not match pending interrupts; missing={missing}, unknown={unknown}" + ) + + checkpoint_ids = {pending.checkpoint_id for pending in current.pending.values()} + if len(checkpoint_ids) != 1 or not next(iter(checkpoint_ids), ""): + raise ValueError("pending interrupts do not share a resumable checkpoint") + + decisions: dict[str, Any] = {} + fingerprints: dict[str, Any] = {} + for entry in entries: + payload = entry.payload if entry.status == "resolved" else {"type": "reject"} + decisions[entry.interrupt_id] = payload + fingerprints[entry.interrupt_id] = self._resume_fingerprint(entry.status, entry.payload) + + single_id = ids[0] if len(ids) == 1 else None + payload_data = decisions[single_id] if single_id is not None else decisions + reservation_created = await self._persist_resolved_approvals(input, current, entries) + if not reservation_created: + return self._duplicate_run(input), True + for interrupt_id, fingerprint in fingerprints.items(): + self._shared.consumed_resumes[(input.thread_id, interrupt_id)] = fingerprint + + resumed = await self._shared.adapter.resume( + current.handle, + ResumeTarget(kind="checkpoint_id", id=next(iter(checkpoint_ids))), + ResumePayload( + kind="approval_decision", + call_id=single_id, + data=payload_data, + ), + ) + current.handle = resumed + current.interrupted = False + current.pending.clear() + return current, False + + async def _persist_resolved_approvals( + self, + input: RunAgentInput, + run: _ThreadRun, + entries: list[Any], + ) -> bool: + if self._shared.event_store_factory is None: + return True + reservation_created = True + for index, entry in enumerate(entries): + fingerprint = self._resume_fingerprint(entry.status, entry.payload) + event_key = json.dumps( + [input.thread_id, entry.interrupt_id], + sort_keys=True, + ensure_ascii=False, + default=str, + ) + decision = self._approval_decision_for_audit(entry.status, entry.payload) + event = RuntimeEvent.create( + EventType.APPROVAL_RESOLVED, + event_id=f"evt_agui_resume_{hashlib.sha256(event_key.encode()).hexdigest()}", + agent_id=str(run.handle.native_ref.get("agent_id") or self.name), + user_id=str(run.handle.native_ref.get("user_id") or "agui-user"), + session_id=input.thread_id, + invocation_id=run.handle.run_id, + seq_id=0, + payload={ + "approval_id": entry.interrupt_id, + "call_id": entry.interrupt_id, + "decision": decision, + "resume_fingerprint": fingerprint, + "protocol": "ag-ui", + }, + ) + if index == 0: + _persisted, reservation_created = await self._reserve(event) + if not reservation_created: + return False + else: + await self._persist(event) + return reservation_created + + @staticmethod + def _approval_decision_for_audit(status: str, payload: Any) -> str: + """Persist a stable decision value while accepting official AG-UI envelopes.""" + + if status != "resolved": + return "rejected" + if payload is True: + return "approved" + if isinstance(payload, Mapping): + for key in ("approve", "approved"): + if key in payload: + return "approved" if bool(payload[key]) else "rejected" + for key in ("decision", "type"): + if key in payload: + return KsadkAGUIAgent._approval_decision_for_audit(status, payload[key]) + return "rejected" + if isinstance(payload, str) and payload.strip().lower() in {"approve", "approved"}: + return "approved" + return "rejected" + + async def _project( + self, + event: RuntimeEvent, + wire: _WireState, + run: _ThreadRun, + ) -> AsyncIterator[BaseEvent]: + payload = event.payload + event_type = event.event_type + + if event_type in (EventType.TEXT_DELTA, EventType.TEXT_COMPLETED): + if not wire.text_open: + wire.text_open = True + yield TextMessageStartEvent(message_id=wire.message_id) + text = str(payload.get("text") or "") + if event_type == EventType.TEXT_COMPLETED and wire.text_content: + text = text[len(wire.text_content) :] if text.startswith(wire.text_content) else "" + if text: + yield TextMessageContentEvent(message_id=wire.message_id, delta=text) + wire.text_content += text + if event_type == EventType.TEXT_COMPLETED: + wire.text_open = False + yield TextMessageEndEvent(message_id=wire.message_id) + return + + if event_type in (EventType.REASONING_DELTA, EventType.REASONING_COMPLETED): + if not wire.reasoning_open: + wire.reasoning_open = True + yield ReasoningStartEvent(message_id=wire.reasoning_id) + yield ReasoningMessageStartEvent(message_id=wire.reasoning_id, role="reasoning") + text = str(payload.get("text") or "") + if text: + yield ReasoningMessageContentEvent(message_id=wire.reasoning_id, delta=text) + if event_type == EventType.REASONING_COMPLETED: + wire.reasoning_open = False + yield ReasoningMessageEndEvent(message_id=wire.reasoning_id) + yield ReasoningEndEvent(message_id=wire.reasoning_id) + return + + if event_type == EventType.TOOL_CALL_BEGIN: + call_id = str(payload.get("call_id") or "tool") + yield ToolCallStartEvent( + tool_call_id=call_id, + tool_call_name=str(payload.get("name") or "tool"), + parent_message_id=wire.message_id, + ) + if "args" in payload: + yield ToolCallArgsEvent( + tool_call_id=call_id, + delta=json.dumps(payload.get("args"), ensure_ascii=False, default=str), + ) + return + + if event_type == EventType.TOOL_CALL_END: + call_id = str(payload.get("call_id") or "tool") + content = ( + payload.get("result") if payload.get("error") is None else payload.get("error") + ) + # AG-UI closes the active call at TOOL_CALL_END. Sending a result + # first makes official clients discard the call, then reject this + # end event as orphaned. + yield ToolCallEndEvent(tool_call_id=call_id) + yield ToolCallResultEvent( + message_id=f"{wire.run_id}:tool:{call_id}", + tool_call_id=call_id, + content=json.dumps(content, ensure_ascii=False, default=str), + role="tool", + ) + return + + if event_type == EventType.CHECKPOINT_CREATED: + yield StateSnapshotEvent(snapshot={"checkpoint": copy.deepcopy(payload)}) + return + + if event_type == EventType.APPROVAL_REQUESTED: + interrupt_id = str(payload.get("approval_id") or payload.get("call_id") or "") + checkpoint_id = str(run.handle.native_ref.get("checkpoint_id") or "") + if not checkpoint_id: + known = run.handle.native_ref.get("known_checkpoint_ids") or [] + checkpoint_id = str(known[-1]) if known else "" + raw_detail = payload.get("detail") + detail: dict[str, Any] = raw_detail if isinstance(raw_detail, dict) else {} + interrupt = Interrupt( + id=interrupt_id, + reason=str(detail.get("reason") or payload.get("kind") or "approval"), + message=self._approval_message(detail, payload), + tool_call_id=str(payload.get("call_id") or "") or None, + response_schema=detail.get("response_schema"), + metadata=self._approval_metadata(detail, payload), + ) + run.pending[interrupt_id] = _PendingInterrupt(interrupt, checkpoint_id) + return + + if event_type in { + EventType.A2UI_SURFACE_BEGIN, + EventType.A2UI_SURFACE_UPDATE, + EventType.A2UI_SURFACE_END, + }: + operations = project_a2ui_operations(event_type, payload) + if operations: + surface_id = str(payload.get("surface_id") or payload.get("surfaceId") or "") + yield ActivitySnapshotEvent( + message_id=f"{wire.run_id}:a2ui:{surface_id}", + activity_type="a2ui-surface", + # The client renderer addresses a surface by this value. + # ``message_id`` is a transport id, not the A2UI surface id. + content={ + "surfaceId": surface_id, + "a2ui_operations": operations, + }, + replace=True, + ) + return + + if event_type == EventType.RUN_INTERRUPTED: + async for close_event in self._close_open_messages(wire): + yield close_event + if not run.pending: + raise ValueError("runtime interrupted without a pending approval") + checkpoint_id = str(run.handle.native_ref.get("checkpoint_id") or "") + if not checkpoint_id: + known = run.handle.native_ref.get("known_checkpoint_ids") or [] + checkpoint_id = str(known[-1]) if known else "" + for pending in run.pending.values(): + if not pending.checkpoint_id: + pending.checkpoint_id = checkpoint_id + run.interrupted = True + wire.terminal = True + yield RunFinishedEvent( + thread_id=wire.thread_id, + run_id=wire.run_id, + outcome=RunFinishedInterruptOutcome( + interrupts=[pending.interrupt for pending in run.pending.values()] + ), + ) + return + + if event_type == EventType.RUN_COMPLETED: + async for finish_event in self._finish_success(wire): + yield finish_event + return + + if event_type in (EventType.RUN_FAILED, EventType.RUN_CANCELED): + async for close_event in self._close_open_messages(wire): + yield close_event + wire.terminal = True + yield RunErrorEvent( + message=( + "Runtime run was cancelled" + if event_type == EventType.RUN_CANCELED + else "Runtime execution failed" + ), + code=("CANCELLED" if event_type == EventType.RUN_CANCELED else "RUNTIME_ERROR"), + ) + + async def _finish_success(self, wire: _WireState) -> AsyncIterator[BaseEvent]: + async for event in self._close_open_messages(wire): + yield event + if not wire.terminal: + wire.terminal = True + yield RunFinishedEvent( + thread_id=wire.thread_id, + run_id=wire.run_id, + outcome=RunFinishedSuccessOutcome(), + ) + + @staticmethod + async def _close_open_messages(wire: _WireState) -> AsyncIterator[BaseEvent]: + if wire.reasoning_open: + wire.reasoning_open = False + yield ReasoningMessageEndEvent(message_id=wire.reasoning_id) + yield ReasoningEndEvent(message_id=wire.reasoning_id) + if wire.text_open: + wire.text_open = False + yield TextMessageEndEvent(message_id=wire.message_id) + + async def _persist(self, event: RuntimeEvent) -> RuntimeEvent: + factory = self._shared.event_store_factory + if factory is None: + return event + store = factory() + return cast(RuntimeEvent, await store.append_one(event)) + + async def _reserve(self, event: RuntimeEvent) -> tuple[RuntimeEvent, bool]: + factory = self._shared.event_store_factory + if factory is None: + return event, True + store = factory() + reserve = getattr(store, "reserve_once", None) + if callable(reserve): + persisted, created = await reserve(event) + return cast(RuntimeEvent, persisted), bool(created) + return cast(RuntimeEvent, await store.append_one(event)), True + + async def _persist_user_input( + self, + input: RunAgentInput, + request: StartRequest, + handle: RunHandle, + ) -> None: + event_key = json.dumps([input.thread_id, input.run_id, "user"], ensure_ascii=False) + await self._persist( + RuntimeEvent.create( + EventType.RUN_STARTED, + event_id=f"evt_agui_input_{hashlib.sha256(event_key.encode()).hexdigest()}", + agent_id=self.name, + user_id=request.user_id, + session_id=input.thread_id, + invocation_id=input.run_id, + seq_id=0, + payload={ + "status": "in_progress", + "input": self._json_safe(request.input), + "source": "ag-ui", + "runtime_type": handle.runtime_type, + }, + ) + ) + await self._prime_session_metadata_for_user_turn( + session_id=input.thread_id, + user_input=self._input_text(request.input), + ) + + async def _prime_session_metadata_for_user_turn( + self, + *, + session_id: str, + user_input: str, + ) -> None: + factory = self._shared.session_service_factory + if factory is None or not user_input: + return + try: + service = factory() + session = await service.get_session(session_id) + if session is not None: + await prime_session_metadata_for_user_turn( + service=service, + session=session, + user_input=user_input, + ) + except Exception: + # Metadata enrichment must not make an otherwise valid agent run fail. + logger.debug("failed to prime AG-UI session metadata", exc_info=True) + + async def _update_session_metadata_after_assistant_turn( + self, + input: RunAgentInput, + wire: _WireState, + ) -> None: + factory = self._shared.session_service_factory + if factory is None or not wire.text_content: + return + try: + await _update_session_metadata_after_assistant_turn( + service=factory(), + session_id=input.thread_id, + assistant_text=wire.text_content, + model=None, + ) + except Exception: + logger.debug("failed to update AG-UI session metadata", exc_info=True) + + def _event_for_persistence(self, event: RuntimeEvent, run: _ThreadRun) -> RuntimeEvent: + if event.event_type in { + EventType.APPROVAL_REQUESTED, + EventType.APPROVAL_RESOLVED, + }: + return event.model_copy(update={"payload": {**event.payload, "protocol": "ag-ui"}}) + if event.event_type != EventType.RUN_INTERRUPTED: + return event + return event.model_copy( + update={ + "payload": { + **event.payload, + "runtime_handle": self._durable_handle(run.handle), + } + } + ) + + async def _restore_durable_run( + self, + input: RunAgentInput, + fingerprints: Mapping[str, str], + ) -> tuple[_ThreadRun | None, bool]: + events = await self._durable_events(input.thread_id) + if not events: + return None, False + resolved = { + str(event.payload.get("approval_id") or event.payload.get("call_id") or ""): event + for event in events + if event.event_type == EventType.APPROVAL_RESOLVED + } + if fingerprints and all(interrupt_id in resolved for interrupt_id in fingerprints): + for interrupt_id, fingerprint in fingerprints.items(): + persisted = str(resolved[interrupt_id].payload.get("resume_fingerprint") or "") + if persisted and persisted != fingerprint: + raise ValueError(f"interrupt {interrupt_id!r} was resolved differently") + return None, True + if any(interrupt_id in resolved for interrupt_id in fingerprints): + raise ValueError("resume set is only partially resolved") + + interrupted = next( + (event for event in reversed(events) if event.event_type == EventType.RUN_INTERRUPTED), + None, + ) + if interrupted is None: + return None, False + raw_handle = interrupted.payload.get("runtime_handle") + if not isinstance(raw_handle, dict): + return None, False + handle = RunHandle.model_validate(raw_handle) + requests = [ + event + for event in events + if event.invocation_id == interrupted.invocation_id + and event.event_type == EventType.APPROVAL_REQUESTED + and str(event.payload.get("approval_id") or event.payload.get("call_id") or "") + not in resolved + ] + pending: dict[str, _PendingInterrupt] = {} + checkpoint_id = str(handle.native_ref.get("checkpoint_id") or "") + if not checkpoint_id: + known = handle.native_ref.get("known_checkpoint_ids") or [] + checkpoint_id = str(known[-1]) if known else "" + for request in requests: + interrupt = self._interrupt_from_payload(request.payload) + 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) + return _ThreadRun(handle=handle, interrupted=True, pending=pending), False + + async def _durable_events(self, session_id: str) -> list[RuntimeEvent]: + factory = self._shared.event_store_factory + if factory is None: + return [] + store = factory() + list_events = getattr(store, "list", None) + if not callable(list_events): + return [] + return list(await list_events(session_id)) + + @staticmethod + def _durable_handle(handle: RunHandle) -> dict[str, Any]: + allowed = { + "agent_id", + "user_id", + "checkpoint_id", + "known_checkpoint_ids", + "pending_approval_ids", + "framework_ref", + "thread_id", + "checkpoint_ns", + "resume_thread_id", + } + native_ref = {key: value for key, value in handle.native_ref.items() if key in allowed} + return { + "run_id": handle.run_id, + "session_id": handle.session_id, + "runtime_type": handle.runtime_type, + "native_ref": KsadkAGUIAgent._json_safe(native_ref), + } + + @staticmethod + def _interrupt_from_payload(payload: Mapping[str, Any]) -> Interrupt: + interrupt_id = str(payload.get("approval_id") or payload.get("call_id") or "") + raw_detail = payload.get("detail") + detail = raw_detail if isinstance(raw_detail, dict) else {} + return Interrupt( + id=interrupt_id, + reason=str(detail.get("reason") or payload.get("kind") or "approval"), + message=KsadkAGUIAgent._approval_message(detail, payload), + tool_call_id=str(payload.get("call_id") or "") or None, + response_schema=detail.get("response_schema"), + metadata=KsadkAGUIAgent._approval_metadata(detail, payload), + ) + + @staticmethod + def _duplicate_run(input: RunAgentInput) -> _ThreadRun: + return _ThreadRun( + handle=RunHandle( + run_id=input.run_id, + session_id=input.thread_id, + runtime_type="ag-ui-duplicate", + ) + ) + + @staticmethod + def _json_safe(value: Any) -> Any: + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + + async def _close_thread(self, thread_id: str, run: _ThreadRun) -> None: + try: + await self._shared.adapter.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) + if result in { + CancelResult.INTERRUPTED_ACTIVE_TURN, + CancelResult.PENDING_CANCEL_RECORDED, + CancelResult.NOT_RUNNING, + }: + await self._close_thread(thread_id, run) + elif result == CancelResult.FAILED: + # Keep the handle blocked in the registry: cancellation was not + # acknowledged, so accepting a replacement run would be dishonest. + run.cancel_failed = True + run.active = True + return result + + @staticmethod + def _latest_user_input(input: RunAgentInput) -> Any: + for message in reversed(input.messages): + if getattr(message, "role", None) == "user": + return getattr(message, "content", "") + return "" + + @staticmethod + def _input_text(value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, Mapping): + return str(value.get("text") or value.get("content") or "").strip() + return str(value or "").strip() + + @staticmethod + def _approval_action(detail: Mapping[str, Any]) -> Mapping[str, Any]: + nested_request = detail.get("approval_requests") + actions = ( + nested_request.get("action_requests") + if isinstance(nested_request, Mapping) + else detail.get("action_requests") + ) + if isinstance(actions, list): + for action in actions: + if isinstance(action, Mapping): + return action + return {} + + @staticmethod + def _approval_metadata( + detail: Mapping[str, Any], + payload: Mapping[str, Any], + ) -> dict[str, Any]: + action = KsadkAGUIAgent._approval_action(detail) + arguments = ( + action.get("args") + or action.get("arguments") + or detail.get("arguments") + or detail.get("args") + or payload.get("args") + ) + metadata: dict[str, Any] = { + "tool_name": str( + action.get("name") + or detail.get("tool_name") + or payload.get("name") + or payload.get("kind") + or "approval" + ), + "arguments": arguments if arguments is not None else {}, + } + approval_level = ( + action.get("approval_level") + or detail.get("approval_level") + or payload.get("approval_level") + ) + if approval_level: + metadata["approval_level"] = str(approval_level) + return metadata + + @staticmethod + def _approval_message(detail: Mapping[str, Any], payload: Mapping[str, Any]) -> str: + action = KsadkAGUIAgent._approval_action(detail) + return str( + action.get("description") + or detail.get("message") + or payload.get("message") + or "Approval required" + ) + + @staticmethod + def _resume_fingerprint(status: str, payload: Any) -> Any: + return json.dumps([status, payload], sort_keys=True, ensure_ascii=False, default=str) + + +__all__ = ["KsadkAGUIAgent"] diff --git a/ksadk/agui/config.py b/ksadk/agui/config.py new file mode 100644 index 00000000..d3386e8a --- /dev/null +++ b/ksadk/agui/config.py @@ -0,0 +1,86 @@ +"""Configuration and dependency gates for the optional AG-UI transport.""" + +from __future__ import annotations + +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version +from typing import Any + +AGUI_PROTOCOL_VERSION = "0.1.19" +AGUI_LANGGRAPH_VERSION = "0.0.42" +COPILOTKIT_VERSION = "0.1.94" + +_REQUIRED_DISTRIBUTIONS = { + "ag-ui-protocol": AGUI_PROTOCOL_VERSION, + "ag-ui-langgraph": AGUI_LANGGRAPH_VERSION, + "copilotkit": COPILOTKIT_VERSION, +} + + +@dataclass(frozen=True) +class AGUIConfig: + """Optional per-app AG-UI endpoint configuration.""" + + enabled: bool = False + path: str = "/agentengine/agui" + agent_name: str = "ksadk" + runtime_type: str = "langgraph" + preferred: bool = True + + +def agui_dependency_errors() -> list[str]: + """Return exact-version dependency mismatches without importing AG-UI eagerly.""" + errors: list[str] = [] + for distribution, expected in _REQUIRED_DISTRIBUTIONS.items(): + try: + actual = version(distribution) + except PackageNotFoundError: + errors.append(f"{distribution}=={expected} is not installed") + continue + if actual != expected: + errors.append(f"{distribution}=={expected} required, found {actual}") + return errors + + +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. + + 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. + """ + 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") + return AGUIConfig( + enabled=runtime_type in ("langgraph", "codex") and agui_dependencies_available(), + agent_name=agent_name, + runtime_type=runtime_type or "unknown", + ) + + +def require_agui_dependencies() -> None: + errors = agui_dependency_errors() + if errors: + detail = "; ".join(errors) + raise RuntimeError( + f"AG-UI transport is enabled but unavailable: {detail}. Install ksadk[agui]." + ) + + +__all__ = [ + "AGUIConfig", + "AGUI_LANGGRAPH_VERSION", + "AGUI_PROTOCOL_VERSION", + "COPILOTKIT_VERSION", + "agui_dependencies_available", + "agui_dependency_errors", + "default_agui_config", + "require_agui_dependencies", +] diff --git a/ksadk/agui/routes.py b/ksadk/agui/routes.py new file mode 100644 index 00000000..792d7f3d --- /dev/null +++ b/ksadk/agui/routes.py @@ -0,0 +1,55 @@ +"""Factory wiring for the official AG-UI FastAPI endpoint helper.""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any, Callable, cast + +from fastapi import FastAPI + +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) + + +def _fastapi_endpoint_helper() -> Callable[..., Any]: + """Resolve the optional untyped helper only after the dependency gate.""" + module = import_module("ag_ui_langgraph") + helper = getattr(module, "add_langgraph_fastapi_endpoint", None) + if not callable(helper): + raise RuntimeError("ag-ui-langgraph does not expose add_langgraph_fastapi_endpoint") + return cast(Callable[..., Any], helper) + + +def add_ksadk_agui_endpoint( + app: FastAPI, + runner: BaseRunner, + config: AGUIConfig, + *, + event_store_factory=None, + session_service_factory=None, +) -> KsadkAGUIAgent: + require_agui_dependencies() + + adapter = build_runtime_adapter(runner, runtime_type=config.runtime_type) + agent = KsadkAGUIAgent( + name=config.agent_name, + adapter=adapter, + event_store_factory=event_store_factory, + session_service_factory=session_service_factory, + ) + _fastapi_endpoint_helper()(app, cast(Any, agent), path=config.path) + return agent + + +__all__ = ["add_ksadk_agui_endpoint", "build_runtime_adapter"] diff --git a/ksadk/api/client.py b/ksadk/api/client.py index ed2e1604..fc83d77f 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -4,17 +4,17 @@ 支持 AWS V4 签名认证,用于通过 KOP 网关访问 AgentEngine Server。 """ -import os -import re import json -import uuid -import socket import logging import mimetypes +import os +import re +import socket +import uuid from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Optional, Dict, Any, Sequence, Callable, Iterator +from typing import Any, Callable, Dict, Iterator, Optional, Sequence from urllib.parse import quote, unquote, urlparse, urlsplit import requests @@ -38,7 +38,9 @@ class AttachmentContent: class DryRunExit(Exception): """DryRun 模式退出异常。""" - def __init__(self, message: str = "Dry Run finished.", *, payload: Optional[Dict[str, Any]] = None): + def __init__( + self, message: str = "Dry Run finished.", *, payload: Optional[Dict[str, Any]] = None + ): self.payload = payload or {} super().__init__(message) @@ -54,6 +56,7 @@ def __init__( details: Optional[Dict[str, Any]] = None, ): self.raw_code = code + self.code: Optional[int] try: self.code = int(code) except (TypeError, ValueError): @@ -69,9 +72,9 @@ def __str__(self) -> str: class AgentEngineClient: """AgentEngine Server API 客户端 - + 使用 AWS V4 签名认证访问 KOP 网关后的 AgentEngine Server。 - + 凭证来源 (优先级从高到低): 1. 构造函数参数 2. 环境变量 KSYUN_ACCESS_KEY / KSYUN_SECRET_KEY @@ -85,8 +88,8 @@ class AgentEngineClient: _permission_probe_cache: dict[tuple[str, str, str], bool] = {} def __init__( - self, - base_url: Optional[str] = None, + self, + base_url: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None, region: str = "cn-beijing-6", @@ -95,13 +98,9 @@ def __init__( dry_run: bool = False, extra_headers: Optional[Dict[str, str]] = None, ): - self.base_url = ( - base_url - or os.getenv("AGENTENGINE_SERVER_URL") - ) - if not self.base_url: - self.base_url = self._detect_default_base_url() - + resolved_base_url = base_url or os.getenv("AGENTENGINE_SERVER_URL") + self.base_url: str = resolved_base_url or self._detect_default_base_url() + # 本地调试覆盖 (如果需要) # self.base_url = "http://localhost:8081" self.timeout = timeout @@ -111,8 +110,8 @@ def __init__( self.dry_run = bool(dry_run or self._is_global_dry_run_enabled()) self.extra_headers = extra_headers or {} # 签名 service 可通过环境变量覆盖(例如 aicp) - self.service = service or os.getenv("AGENTENGINE_SIGN_SERVICE", "aicp") - + self.service: str = service or os.getenv("AGENTENGINE_SIGN_SERVICE") or "aicp" + # AWS V4 签名 self._auth = AWSV4Auth( access_key_id=access_key or "", @@ -120,12 +119,12 @@ def __init__( region=self.region, service=self.service, ) - + if self._auth.is_enabled: logger.debug(f"AgentEngineClient initialized with V4Auth: {self.base_url}") else: logger.debug("AgentEngineClient: No credentials, signing disabled") - + self._session: Optional[requests.Session] = None self._http_error_log_suppressors: list[HttpErrorLogSuppressor] = [] # 反查身份的实例缓存(避免同会话重复调 IAM);None=未尝试,ResolvedIdentity|None=已反查 @@ -135,10 +134,10 @@ def __init__( @staticmethod def _ssl_verify_enabled() -> bool: insecure = ( - os.getenv("AGENTENGINE_SSL_INSECURE") - or os.getenv("CURL_SSL_INSECURE") - or "" - ).strip().lower() + (os.getenv("AGENTENGINE_SSL_INSECURE") or os.getenv("CURL_SSL_INSECURE") or "") + .strip() + .lower() + ) if insecure in {"1", "true", "yes", "on"}: urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) return False @@ -303,7 +302,9 @@ def _extract_http_error_details(resp_text: str) -> Dict[str, Any]: @staticmethod def _is_auth_related_error_details(details: Dict[str, Any]) -> bool: remote_code = str(details.get("remote_error_code") or "").strip().lower() - remote_message = str(details.get("remote_error_message") or details.get("message") or "").strip().lower() + remote_message = ( + str(details.get("remote_error_message") or details.get("message") or "").strip().lower() + ) if remote_code in { "missingaccesskey", "missingsecretkey", @@ -336,7 +337,9 @@ def _is_auth_related_error_details(details: Dict[str, Any]) -> bool: @staticmethod def _is_inner_account_intranet_error(details: Dict[str, Any]) -> bool: remote_code = str(details.get("remote_error_code") or "").strip().lower() - remote_message = str(details.get("remote_error_message") or details.get("message") or "").strip().lower() + remote_message = ( + str(details.get("remote_error_message") or details.get("message") or "").strip().lower() + ) return ( remote_code == "inneraccountcanonlyaccessthroughintranet" or "inner account can only access through intranet" in remote_message @@ -356,7 +359,9 @@ def _switch_to_inner_aicp_endpoint(self) -> None: ) self.base_url = self._INNER_AICP_BASE_URL - def _build_action_request_target(self, path: str, action: str) -> tuple[bool, Dict[str, str], str]: + def _build_action_request_target( + self, path: str, action: str + ) -> tuple[bool, Dict[str, str], str]: kop_mode = self._is_kop_mode() headers = self._build_headers(action=action, kop_mode=kop_mode) if kop_mode: @@ -366,14 +371,17 @@ def _build_action_request_target(self, path: str, action: str) -> tuple[bool, Di full_url = f"{self.base_url}{path}" return kop_mode, headers, full_url - def _build_raw_action_request_target(self, action: str, accept: str, has_files: bool) -> tuple[bool, Dict[str, str], str]: + def _build_raw_action_request_target( + self, action: str, accept: str, has_files: bool + ) -> tuple[bool, Dict[str, str], str]: kop_mode = self._is_kop_mode() headers = self._build_headers(action=action, kop_mode=kop_mode) headers["Accept"] = accept if has_files: headers.pop("Content-Type", None) + version = os.getenv("AGENTENGINE_API_VERSION", "2024-06-12") full_url = ( - f"{self.base_url.rstrip('/')}/?Action={action}&Version={os.getenv('AGENTENGINE_API_VERSION', '2024-06-12')}" + f"{self.base_url.rstrip('/')}/?Action={action}&Version={version}" if kop_mode else f"{self.base_url}/agentengine/api/v1/{action}" ) @@ -394,7 +402,9 @@ def suppress_http_error_logging( if self._http_error_log_suppressors: self._http_error_log_suppressors.pop() - def _log_http_error(self, *, method: str, full_url: str, status_code: int, details: Dict[str, Any]) -> None: + def _log_http_error( + self, *, method: str, full_url: str, status_code: int, details: Dict[str, Any] + ) -> None: for suppressor in reversed(self._http_error_log_suppressors): try: if suppressor( @@ -433,7 +443,9 @@ def _safe_log_error_summary(*, details: Dict[str, Any]) -> str: message = " ".join(message.split())[:200] return f"{code}: {message}" if code else message - def _build_headers(self, request_id: str = "", action: str = "", kop_mode: bool = False) -> Dict[str, str]: + def _build_headers( + self, request_id: str = "", action: str = "", kop_mode: bool = False + ) -> Dict[str, str]: if not request_id: request_id = str(uuid.uuid4()) headers = { @@ -508,9 +520,7 @@ def _get_resolved_identity(self) -> Any: # dry-run 不联网,只读缓存 self._resolved_identity = get_cached_identity(ak) else: - self._resolved_identity = resolve_identity( - access_key=ak, secret_key=sk - ) + self._resolved_identity = resolve_identity(access_key=ak, secret_key=sk) except Exception as e: logger.warning("反查子账号身份失败: %s", e) self._resolved_identity = None @@ -536,9 +546,7 @@ def _resolve_permission_role_name(self, action: str, params: Dict[str, Any]) -> access = params.get("Access") if isinstance(params, dict) else None if isinstance(access, dict): explicit_role = ( - access.get("IamRole") - or access.get("iamRole") - or access.get("iam_role") + access.get("IamRole") or access.get("iamRole") or access.get("iam_role") ) if str(explicit_role or "").strip(): return str(explicit_role).strip() @@ -597,9 +605,9 @@ def _maybe_precheck_permission(self, action: str, params: Dict[str, Any]) -> Non self._permission_probe_cache[cache_key] = True def _request( - self, + self, method: str, - path: str, + path: str, body: Optional[Dict[str, Any]] = None, *, ignore_dry_run: bool = False, @@ -608,7 +616,7 @@ def _request( action = path.rstrip("/").split("/")[-1] if path else "" _kop_mode, headers, full_url = self._build_action_request_target(path, action) body_str = json.dumps(body, ensure_ascii=False) if body else "" - + # DryRun 模式 if self.dry_run and not ignore_dry_run: signed_headers = headers @@ -622,9 +630,9 @@ def _request( ) # 生成 Curl 命令 - curl_cmd = f"curl -X {method} \"{full_url}\" \\\n" + curl_cmd = f'curl -X {method} "{full_url}" \\\n' for k, v in signed_headers.items(): - curl_cmd += f" -H \"{k}: {v}\" \\\n" + curl_cmd += f' -H "{k}: {v}" \\\n' if body_str: curl_cmd += f" -d '{body_str}'" @@ -639,7 +647,7 @@ def _request( "curl": curl_cmd, }, ) - + session = self._get_session() retried_inner_endpoint = False @@ -685,31 +693,36 @@ def _request( if response.text: try: - return response.json() + payload = response.json() except Exception as e: raise Exception(f"Invalid JSON response from {full_url}: {response.text}") from e + if not isinstance(payload, dict): + raise Exception(f"Invalid JSON response from {full_url}: expected an object") + return payload return {} - + # Async 包装 (保持兼容性) async def close(self): if self._session: self._session.close() self._session = None - + async def __aenter__(self): return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() # ===== Action API (推荐使用) ===== - + @staticmethod def _pascal_key(key: str) -> str: """PascalCase/camelCase 键名转 snake_case""" # "MCPs" -> "mcps", "AgentId" -> "agent_id", "QuickAccess" -> "quick_access" - s1 = re.sub('([A-Z]+)([A-Z][a-z])', r'\1_\2', key) - s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1) + if re.fullmatch(r"[A-Z]+s", key): + return key.lower() + s1 = re.sub("([A-Z]+)([A-Z][a-z])", r"\1_\2", key) + s2 = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1) return s2.lower() @classmethod @@ -747,7 +760,7 @@ def _parse_container_image_ref(image_ref: str) -> tuple[str, str, str]: image = raw for prefix in ("http://", "https://"): if image.startswith(prefix): - image = image[len(prefix):] + image = image[len(prefix) :] break image_no_tag = image @@ -798,9 +811,8 @@ def _build_container_config_payload( img_ns, img_repo, img_ver = cls._parse_container_image_ref(artifact) inferred_enterprise_instance = cls._enterprise_instance_from_image_ref(artifact) - image_type = ( - container.get("image_type") - or ("Enterprise" if inferred_enterprise_instance else "Personal") + image_type = container.get("image_type") or ( + "Enterprise" if inferred_enterprise_instance else "Personal" ) params: Dict[str, Any] = { "ImageType": image_type, @@ -860,14 +872,13 @@ def _extract_runtime_access(detail: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(detail, dict): return {} - basic = detail.get("basic") if isinstance(detail.get("basic"), dict) else {} - quick = detail.get("quick_access") if isinstance(detail.get("quick_access"), dict) else {} - deployment = detail.get("deployment") if isinstance(detail.get("deployment"), dict) else {} - framework = ( - deployment.get("framework") - or basic.get("framework") - or detail.get("framework") - ) + basic_value = detail.get("basic") + quick_value = detail.get("quick_access") + deployment_value = detail.get("deployment") + basic: Dict[str, Any] = basic_value if isinstance(basic_value, dict) else {} + quick: Dict[str, Any] = quick_value if isinstance(quick_value, dict) else {} + deployment: Dict[str, Any] = deployment_value if isinstance(deployment_value, dict) else {} + framework = deployment.get("framework") or basic.get("framework") or detail.get("framework") return { "agent_id": basic.get("agent_id") or detail.get("agent_id"), "name": basic.get("name") or detail.get("name"), @@ -896,18 +907,13 @@ def _workspace_runtime_error(self, response: requests.Response) -> AgentEngineAP payload = None if isinstance(payload, dict): message = ( - str(payload.get("detail") or payload.get("Message") or message).strip() - or message + str(payload.get("detail") or payload.get("Message") or message).strip() or message ) return AgentEngineAPIError(response.status_code, message) @staticmethod def _compact_params(params: Dict[str, Any] | None) -> Dict[str, Any]: - return { - key: value - for key, value in (params or {}).items() - if value is not None - } + return {key: value for key, value in (params or {}).items() if value is not None} @staticmethod def _workspace_prefers_action_proxy(detail: Dict[str, Any] | None) -> bool: @@ -938,13 +944,15 @@ async def _resolve_workspace_transport( }, } - detail = await self.get_agent(agent_id=agent_id, name=name, include_api_key=True) + detail = await self.get_agent( + agent_id=agent_id, + name=name, + include_api_key=True, + ) access = self._extract_runtime_access(detail) resolved_agent_id = str(access.get("agent_id") or agent_id or "").strip() or None resolved_name = str(access.get("name") or name or "").strip() or None - resolved_api_key = api_key_value or ( - str(access.get("api_key") or "").strip() or None - ) + resolved_api_key = api_key_value or (str(access.get("api_key") or "").strip() or None) if self._workspace_prefers_action_proxy(detail): return { "mode": "action", @@ -1039,8 +1047,13 @@ async def _resolve_workspace_runtime_access( api_key=api_key, ) if transport["mode"] != "runtime": - raise AgentEngineAPIError(400, "Workspace runtime direct access is unavailable for this agent") - return transport["access"] + raise AgentEngineAPIError( + 400, "Workspace runtime direct access is unavailable for this agent" + ) + access = transport.get("access") + if not isinstance(access, dict): + raise AgentEngineAPIError(502, "Workspace runtime access payload is invalid") + return access def _workspace_runtime_request( self, @@ -1075,8 +1088,7 @@ def _workspace_runtime_json(self, response: requests.Response) -> Dict[str, Any] payload = response.json() except Exception as exc: url = str( - getattr(response, "_ksadk_workspace_url", "") - or getattr(response, "url", "") + getattr(response, "_ksadk_workspace_url", "") or getattr(response, "url", "") ).strip() body = (response.text or "").strip() body_preview = body[:200] if body else "" @@ -1093,10 +1105,15 @@ def _workspace_runtime_json(self, response: requests.Response) -> Dict[str, Any] "workspace_runtime_body_preview": body_preview, }, ) from exc - return self._to_snake_case(payload) + normalized = self._to_snake_case(payload) + if not isinstance(normalized, dict): + raise AgentEngineAPIError(502, "workspace runtime returned a non-object JSON payload") + return normalized @staticmethod - def _annotate_workspace_payload(payload: Dict[str, Any], *, transport_mode: str) -> Dict[str, Any]: + def _annotate_workspace_payload( + payload: Dict[str, Any], *, transport_mode: str + ) -> Dict[str, Any]: annotated = dict(payload or {}) annotated["transport_mode"] = transport_mode return annotated @@ -1117,7 +1134,7 @@ def _display_name_from_content_disposition(value: str) -> str: def _action( self, action: str, - params: Dict[str, Any] = None, + params: Optional[Dict[str, Any]] = None, *, ignore_dry_run: bool = False, ) -> Dict[str, Any]: @@ -1126,24 +1143,31 @@ def _action( self._maybe_precheck_permission(action, body) request_kwargs = {"ignore_dry_run": True} if ignore_dry_run else {} result = self._request("POST", f"/agentengine/api/v1/{action}", body, **request_kwargs) - + # 检查错误 (统一返回格式 {"Code": 0, ...}) code = result.get("Code", 0) if code != 0: msg = result.get("Message", "Unknown API error") - raise AgentEngineAPIError(code=code, message=msg) - + details = {} + if result.get("RequestId"): + details["request_id"] = str(result["RequestId"]) + if result.get("Action"): + details["action"] = str(result["Action"]) + raise AgentEngineAPIError(code=code, message=msg, details=details) + if result.get("Error"): raise Exception(result["Error"].get("Message", "Unknown error")) - + # 提取 Data 并统一转换为 snake_case data = result.get("Data") if result.get("Data") is not None else result data = self._to_snake_case(data) - + # 预发环境 endpoint 协议归一化: https -> http if self.logical_region and self.logical_region.strip().lower() == "pre-online": data = self._fix_endpoints_protocol(data) - + + if not isinstance(data, dict): + raise AgentEngineAPIError(502, "Action API returned a non-object payload") return data def download_attachment_content(self, file_uri: str) -> AttachmentContent: @@ -1182,7 +1206,12 @@ def _pick(*keys: str, default: Any = None) -> Any: payload: Dict[str, Any] = { "EnablePublicAccess": bool( - _pick("enable_public_access", "enablePublicAccess", "EnablePublicAccess", default=False) + _pick( + "enable_public_access", + "enablePublicAccess", + "EnablePublicAccess", + default=False, + ) ), "EnableVpcAccess": bool( _pick("enable_vpc_access", "enableVpcAccess", "EnableVpcAccess", default=False) @@ -1203,7 +1232,9 @@ def _pick(*keys: str, default: Any = None) -> Any: return payload @staticmethod - def _normalize_ui_config_payload(ui_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + def _normalize_ui_config_payload( + ui_config: Optional[Dict[str, Any]], + ) -> Optional[Dict[str, Any]]: if not isinstance(ui_config, dict): return None @@ -1234,10 +1265,7 @@ def _normalize_storage_payload(storage: Optional[Dict[str, Any]]) -> Optional[Di return None mount_path = str( - storage.get("mount_path") - or storage.get("mountPath") - or storage.get("MountPath") - or "" + storage.get("mount_path") or storage.get("mountPath") or storage.get("MountPath") or "" ).strip() size_gi = storage.get("size_gi", storage.get("sizeGi", storage.get("SizeGi"))) if not mount_path and size_gi is None: @@ -1251,14 +1279,14 @@ def _normalize_storage_payload(storage: Optional[Dict[str, Any]]) -> Optional[Di return payload @staticmethod - def _normalize_memory_config_payload(memory_config: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + def _normalize_memory_config_payload( + memory_config: Optional[Dict[str, Any]], + ) -> Optional[Dict[str, Any]]: if not isinstance(memory_config, dict): return None memory_system = str( - memory_config.get("memory_system") - or memory_config.get("MemorySystem") - or "" + memory_config.get("memory_system") or memory_config.get("MemorySystem") or "" ).strip() if not memory_system: return None @@ -1268,17 +1296,12 @@ def _normalize_memory_config_payload(memory_config: Optional[Dict[str, Any]]) -> } field_mapping = { "Mem0InstanceId": ( - memory_config.get("mem0_instance_id") - or memory_config.get("Mem0InstanceId") + memory_config.get("mem0_instance_id") or memory_config.get("Mem0InstanceId") ), "Mem0InstanceName": ( - memory_config.get("mem0_instance_name") - or memory_config.get("Mem0InstanceName") - ), - "Mem0Region": ( - memory_config.get("mem0_region") - or memory_config.get("Mem0Region") + memory_config.get("mem0_instance_name") or memory_config.get("Mem0InstanceName") ), + "Mem0Region": (memory_config.get("mem0_region") or memory_config.get("Mem0Region")), } for key, value in field_mapping.items(): text = str(value or "").strip() @@ -1297,7 +1320,9 @@ async def create_agent(self, data: Dict[str, Any]) -> Dict[str, Any]: "Region": self._normalize_payload_region(data.get("region", "cn-beijing-6")), "Resource": { "Cpu": int(float(data.get("resources", {}).get("cpu", 2))), - "Memory": int(str(data.get("resources", {}).get("memory", "4Gi")).replace("Gi", "")) + "Memory": int( + str(data.get("resources", {}).get("memory", "4Gi")).replace("Gi", "") + ), }, "Scaling": { "MinReplicas": int(data.get("scaling", {}).get("min_replicas", 1)), @@ -1331,7 +1356,7 @@ async def create_agent(self, data: Dict[str, Any]) -> Dict[str, Any]: "IamRole": data.get("iam_role", "KsyunAgentEngineDefaultRole"), } - if params["DeploymentType"] == "Code": + if params["DeploymentType"] in {"Code", "ManagedRuntime"}: ks3 = data.get("ks3", {}) params["CodeConfig"] = { "Path": data.get("artifact_path", ""), @@ -1340,6 +1365,13 @@ async def create_agent(self, data: Dict[str, Any]) -> Dict[str, Any]: "Region": self._normalize_payload_region(ks3.get("region", "cn-beijing-6")), "Bucket": ks3.get("bucket"), } + if params["DeploymentType"] == "ManagedRuntime": + runtime_config = data.get("runtime_config") or {} + params["RuntimeConfig"] = { + "Name": runtime_config.get("name"), + "Version": runtime_config.get("version"), + "ManifestSha256": runtime_config.get("manifest_sha256"), + } else: ic = data.get("image_credential", {}) or {} artifact = (data.get("artifact_path", "") or "").strip() @@ -1371,7 +1403,12 @@ async def create_agent(self, data: Dict[str, Any]) -> Dict[str, Any]: return self._action("CreateAgentProduct", params) - async def get_agent(self, agent_id: str = None, name: str = None, include_api_key: bool = False) -> Dict[str, Any]: + async def get_agent( + self, + agent_id: Optional[str] = None, + name: Optional[str] = None, + include_api_key: bool = False, + ) -> Dict[str, Any]: """获取 Agent 详情(支持 AgentId 或 Name 查询)""" if agent_id: params: Dict[str, Any] = {"AgentId": agent_id} @@ -1382,7 +1419,7 @@ async def get_agent(self, agent_id: str = None, name: str = None, include_api_ke except Exception as e: # 兼容旧控制面:极少数版本仍使用 Id 字段 if self._should_fallback_get_agent_with_legacy_id(e): - fallback = {"Id": agent_id} + fallback: Dict[str, Any] = {"Id": agent_id} if include_api_key: fallback["IncludeApiKey"] = True return self._action("GetAgent", fallback) @@ -1512,7 +1549,7 @@ async def list_dashboard_access_links( async def delete_dashboard_access_link(self, *, link_id: str) -> Dict[str, Any]: """删除 Dashboard 短链接。""" return self._action("DeleteDashboardAccessLink", {"LinkId": link_id}) - + async def list_agents( self, region: Optional[str] = None, @@ -1595,17 +1632,18 @@ async def delete_agent(self, agent_id: str) -> bool: self._action("DeleteAgent", {"AgentId": agent_id}) return True - - async def update_agent(self, agent_id: str, data: Dict[str, Any]) -> Dict[str, Any]: """更新 Agent (热更新)""" - params = {"AgentId": agent_id} + params: Dict[str, Any] = {"AgentId": agent_id} + artifact_type = data.get("artifact_type") + if artifact_type: + params["DeploymentType"] = artifact_type if data.get("description"): params["Description"] = data["description"] - + if data.get("artifact_path"): artifact = (data.get("artifact_path", "") or "").strip() - if (data.get("artifact_type") or "").lower() == "container": + if (artifact_type or "").lower() == "container": ic = data.get("image_credential", {}) or {} params["ContainerConfig"] = self._build_container_config_payload( artifact=artifact, @@ -1620,20 +1658,27 @@ async def update_agent(self, agent_id: str, data: Dict[str, Any]) -> Dict[str, A "Region": self._normalize_payload_region(ks3.get("region", "cn-beijing-6")), "Bucket": ks3.get("bucket"), } - + if artifact_type == "ManagedRuntime": + runtime_config = data.get("runtime_config") or {} + params["RuntimeConfig"] = { + "Name": runtime_config.get("name"), + "Version": runtime_config.get("version"), + "ManifestSha256": runtime_config.get("manifest_sha256"), + } + if data.get("resources"): params["Resource"] = { "Cpu": int(float(data["resources"].get("cpu", 2))), - "Memory": int(str(data["resources"].get("memory", "4Gi")).replace("Gi", "")) + "Memory": int(str(data["resources"].get("memory", "4Gi")).replace("Gi", "")), } - + if data.get("scaling"): params["Scaling"] = { "MinReplicas": int(data["scaling"].get("min_replicas", 1)), "MaxReplicas": int(data["scaling"].get("max_replicas", 10)), "Concurrency": int(data["scaling"].get("concurrency", 20)), } - + envs = data.get("env_vars") or data.get("environment_variables") if envs: env_vars = [] @@ -1683,27 +1728,27 @@ async def update_agent(self, agent_id: str, data: Dict[str, Any]) -> Dict[str, A advanced["ProjectId"] = project_id if advanced: params["Advanced"] = advanced - + return self._action("UpdateAgent", params) # ===== Session Actions ===== - - async def create_session(self, agent_id: str, user_id: Optional[str] = None, expires_hours: int = 24) -> Dict[str, Any]: + + async def create_session( + self, agent_id: str, user_id: Optional[str] = None, expires_hours: int = 24 + ) -> Dict[str, Any]: """创建会话""" - return self._action("CreateSession", { - "AgentId": agent_id, - "UserId": user_id, - "ExpiresHours": expires_hours - }) - + return self._action( + "CreateSession", {"AgentId": agent_id, "UserId": user_id, "ExpiresHours": expires_hours} + ) + async def get_session(self, session_id: str) -> Dict[str, Any]: """获取会话详情""" return self._action("GetSession", {"Id": session_id}) - + async def list_sessions(self, agent_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]: """列出会话""" return self._action("ListSessions", {"AgentId": agent_id, "Page": page, "PageSize": size}) - + async def delete_session(self, session_id: str) -> bool: """删除会话""" try: @@ -1866,14 +1911,14 @@ async def download_workspace_file( }, accept="application/octet-stream", ) - return response.content + return bytes(response.content) access = transport["access"] response = self._workspace_runtime_request( access=access, method="GET", path=f"files/{self._encode_workspace_runtime_path(remote_path)}", ) - return response.content + return bytes(response.content) async def delete_workspace_file( self, @@ -1944,13 +1989,17 @@ def _normalize_mcp_memory(memory: Any, default: int = 2) -> int: return int(float(raw)) @staticmethod - def _normalize_mcp_deployment_type(value: Optional[str], default: Optional[str] = None) -> Optional[str]: + def _normalize_mcp_deployment_type( + value: Optional[str], default: Optional[str] = None + ) -> Optional[str]: raw = str(value or default or "").strip() if not raw: return None return "Container" if raw.lower() == "container" else "Code" - def _infer_mcp_deployment_type(self, data: Dict[str, Any], default: Optional[str] = None) -> Optional[str]: + def _infer_mcp_deployment_type( + self, data: Dict[str, Any], default: Optional[str] = None + ) -> Optional[str]: explicit = self._normalize_mcp_deployment_type( data.get("deployment_type") or data.get("artifact_type"), default=default, @@ -2018,11 +2067,14 @@ def _build_mcp_advanced(data: Dict[str, Any]) -> Dict[str, Any]: async def create_mcp(self, data: Dict[str, Any]) -> Dict[str, Any]: """创建 MCP""" if self._looks_like_nested_mcp_payload(data): - params = dict(data) - params["Region"] = self._normalize_payload_region( - params.get("Region") or data.get("region") or self.logical_region or "cn-beijing-6" + nested_params = dict(data) + nested_params["Region"] = self._normalize_payload_region( + nested_params.get("Region") + or data.get("region") + or self.logical_region + or "cn-beijing-6" ) - return self._action("CreateMCP", params) + return self._action("CreateMCP", nested_params) deployment_type = self._infer_mcp_deployment_type(data, default="Code") or "Code" params: Dict[str, Any] = { @@ -2043,11 +2095,11 @@ async def create_mcp(self, data: Dict[str, Any]) -> Dict[str, Any]: if network_payload: params["Network"] = network_payload return self._action("CreateMCP", params) - + async def get_mcp(self, mcp_id: str) -> Dict[str, Any]: """获取 MCP 详情""" return self._action("GetMCP", {"Id": mcp_id}) - + async def list_mcps( self, region: Optional[str] = None, @@ -2055,24 +2107,23 @@ async def list_mcps( page_size: int = 20, ) -> Dict[str, Any]: """列出 MCP (注册中心)""" - params = {"Page": int(page), "PageSize": int(page_size)} + params: Dict[str, Any] = {"Page": int(page), "PageSize": int(page_size)} if region: params["Region"] = self._normalize_payload_region(region) result = self._action("ListMCPs", params) - # _action 已统一转 snake_case: "MCPs" -> "m_c_ps"... - # 实际上 MCPs -> mcps, Total -> total + # _action 已统一转 snake_case: MCPs -> mcps, Total -> total return {"mcps": result.get("mcps", []), "total": result.get("total", 0)} - + async def update_mcp(self, mcp_id: str, data: Dict[str, Any]) -> Dict[str, Any]: """更新 MCP (热更新)""" if self._looks_like_nested_mcp_payload(data): - params = dict(data) - params.setdefault("Id", mcp_id) - if "Region" in params or data.get("region") or self.logical_region: - params["Region"] = self._normalize_payload_region( - params.get("Region") or data.get("region") or self.logical_region + nested_params = dict(data) + nested_params.setdefault("Id", mcp_id) + if "Region" in nested_params or data.get("region") or self.logical_region: + nested_params["Region"] = self._normalize_payload_region( + nested_params.get("Region") or data.get("region") or self.logical_region ) - return self._action("UpdateMCP", params) + return self._action("UpdateMCP", nested_params) params: Dict[str, Any] = {"Id": mcp_id} deployment_type = self._infer_mcp_deployment_type(data) @@ -2096,7 +2147,9 @@ async def update_mcp(self, mcp_id: str, data: Dict[str, Any]) -> Dict[str, Any]: } scaling = data.get("scaling", {}) or {} - if any(scaling.get(key) is not None for key in ("min_replicas", "max_replicas", "concurrency")): + if any( + scaling.get(key) is not None for key in ("min_replicas", "max_replicas", "concurrency") + ): params["Scaling"] = { "MinReplicas": int(scaling.get("min_replicas", 1)), "MaxReplicas": int(scaling.get("max_replicas", 5)), @@ -2119,16 +2172,15 @@ async def update_mcp(self, mcp_id: str, data: Dict[str, Any]) -> Dict[str, Any]: if data.get("region") is not None: params["Region"] = self._normalize_payload_region(data.get("region")) return self._action("UpdateMCP", params) - + async def delete_mcp(self, mcp_id: str) -> bool: """删除 MCP""" - try: - self._action("DeleteMCP", {"Id": mcp_id}) - return True - except Exception: - return False - - async def get_mcp_by_name(self, name: str, region: Optional[str] = None) -> Optional[Dict[str, Any]]: + result = self._action("DeleteMCP", {"Id": mcp_id}) + return bool(result.get("deleted")) + + async def get_mcp_by_name( + self, name: str, region: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """按名称查询 MCP""" # 使用 ListMCPs 然后过滤 (Action API 暂不支持 by-name) try: @@ -2138,7 +2190,7 @@ async def get_mcp_by_name(self, name: str, region: Optional[str] = None) -> Opti result = await self.list_mcps(region=region, page=page, page_size=page_size) mcps = result.get("mcps", []) for mcp in mcps: - if mcp.get("name") == name: + if isinstance(mcp, dict) and mcp.get("name") == name: return mcp if len(mcps) < page_size: break @@ -2148,39 +2200,38 @@ async def get_mcp_by_name(self, name: str, region: Optional[str] = None) -> Opti return None # ===== Upload Actions ===== - + async def get_presigned_url(self, filename: str) -> Dict[str, Any]: """获取 KS3 预签名上传 URL""" return self._action("GetPresignedUrl", {"Filename": filename}) # ===== Chat Actions ===== - - async def chat(self, agent_id: str, message: str, session_id: Optional[str] = None) -> Dict[str, Any]: + + async def chat( + self, agent_id: str, message: str, session_id: Optional[str] = None + ) -> Dict[str, Any]: """调用 Agent""" params = { "AgentId": agent_id, "Messages": [{"role": "user", "content": message}], - "Stream": False + "Stream": False, } if session_id: params["SessionId"] = session_id return self._action("RunAgent", params) # ===== Version Actions ===== - + async def release_version( - self, - agent_id: str, - tag: Optional[str] = None, - description: Optional[str] = None + self, agent_id: str, tag: Optional[str] = None, description: Optional[str] = None ) -> Dict[str, Any]: """发布新版本 - + Args: agent_id: Agent ID tag: 版本标签,不填则自动生成 description: 版本描述 - + Returns: 版本信息 """ @@ -2190,46 +2241,37 @@ async def release_version( if description: params["Description"] = description return self._action("CreateVersion", params) - - async def list_versions( - self, - agent_id: str, - page: int = 1, - size: int = 10 - ) -> Dict[str, Any]: + + async def list_versions(self, agent_id: str, page: int = 1, size: int = 10) -> Dict[str, Any]: """列出版本历史 - + Args: agent_id: Agent ID page: 页码 size: 每页数量 - + Returns: 版本列表和分页信息 """ - return self._action("ListVersions", { - "AgentId": agent_id, - "Page": page, - "PageSize": size - }) - + return self._action("ListVersions", {"AgentId": agent_id, "Page": page, "PageSize": size}) + async def rollback_version( - self, - agent_id: str, + self, + agent_id: str, target_version_id: Optional[str] = None, target_tag: Optional[str] = None, ks3_access_key: Optional[str] = None, ks3_secret_key: Optional[str] = None, ) -> Dict[str, Any]: """回滚到指定版本 - + Args: agent_id: Agent ID target_version_id: 目标版本 ID(与 target_tag 二选一) target_tag: 目标版本标签(与 target_version_id 二选一) ks3_access_key: KS3 Access Key (可选) ks3_secret_key: KS3 Secret Key (可选) - + Returns: 回滚结果 """ @@ -2242,5 +2284,5 @@ async def rollback_version( params["KS3AccessKey"] = ks3_access_key if ks3_secret_key: params["KS3SecretKey"] = ks3_secret_key - + return self._action("RollbackVersion", params) diff --git a/ksadk/builders/__init__.py b/ksadk/builders/__init__.py index ecd1cab8..5404426b 100644 --- a/ksadk/builders/__init__.py +++ b/ksadk/builders/__init__.py @@ -10,6 +10,7 @@ from ksadk.builders.code_builder import CodeBuilder from ksadk.builders.container_builder import ContainerBuilder from ksadk.builders.ks3_uploader import KS3Uploader +from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder __all__ = [ "BaseBuilder", @@ -17,4 +18,5 @@ "CodeBuilder", "ContainerBuilder", "KS3Uploader", + "ManagedRuntimeBuilder", ] diff --git a/ksadk/builders/base.py b/ksadk/builders/base.py index 0c5669bd..fd6796d8 100644 --- a/ksadk/builders/base.py +++ b/ksadk/builders/base.py @@ -11,12 +11,13 @@ @dataclass class BuildResult: """构建结果""" + success: bool artifact_path: Optional[Path] = None artifact_size: int = 0 # bytes metadata: Dict[str, Any] = field(default_factory=dict) error_message: Optional[str] = None - + @property def artifact_size_mb(self) -> float: """获取 artifact 大小 (MB)""" @@ -25,42 +26,43 @@ def artifact_size_mb(self) -> float: class BaseBuilder(ABC): """构建器基类""" - - def __init__(self, project_dir: Path, config: Dict[str, Any] = None): + + def __init__(self, project_dir: Path, config: Optional[Dict[str, Any]] = None): self.project_dir = Path(project_dir).resolve() self.config = config or {} - + @abstractmethod def build(self) -> BuildResult: """执行构建 - + Returns: BuildResult: 构建结果 """ pass - + def _load_config(self) -> Dict[str, Any]: """加载项目配置文件""" import yaml - - config_path = self.project_dir / 'agentengine.yaml' + + config_path = self.project_dir / "agentengine.yaml" if not config_path.exists(): - config_path = self.project_dir / 'ksadk.yaml' - + config_path = self.project_dir / "ksadk.yaml" + if config_path.exists(): # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 - with open(config_path, encoding='utf-8-sig') as f: + with open(config_path, encoding="utf-8-sig") as f: return yaml.safe_load(f) or {} - + return {} - + def _load_dotenv(self) -> None: """加载项目目录的 .env 文件""" env_file = self.project_dir / ".env" if env_file.exists(): try: from dotenv import load_dotenv + # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 - load_dotenv(env_file, override=True, encoding='utf-8-sig') + load_dotenv(env_file, override=True, encoding="utf-8-sig") except ImportError: pass diff --git a/ksadk/builders/code_builder.py b/ksadk/builders/code_builder.py index d7e95445..232ea357 100644 --- a/ksadk/builders/code_builder.py +++ b/ksadk/builders/code_builder.py @@ -7,17 +7,17 @@ 3. 打包 zip (用户代码 + 依赖 + ksadk 源码 + entrypoint) """ +import ast +import hashlib +import json import os -import sys +import re import shutil import subprocess +import sys import threading import time import zipfile -import re -import json -import hashlib -import ast from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import List, Optional, Set @@ -25,11 +25,12 @@ from urllib.request import Request, urlopen import click +from packaging.requirements import InvalidRequirement, Requirement from ksadk.builders.base import BaseBuilder, BuildResult from ksadk.builders.framework_requirements import ( FASTAPI_REQUIREMENT, - requirements_for_framework, + code_requirements_for_framework, ) from ksadk.builders.requirements_utils import ( exclude_requirement_names, @@ -85,28 +86,28 @@ class CodeBuilder(BaseBuilder): } IGNORED_FILE_NAMES = {".DS_Store"} KSADK_ALLOWED_SUFFIXES = { - '.py', - '.yaml', - '.yml', - '.json', - '.jinja2', - '.j2', - '.txt', - '.md', - '.html', - '.js', - '.css', - '.svg', - '.ico', - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.woff', - '.woff2', - '.ttf', - '.map', + ".py", + ".yaml", + ".yml", + ".json", + ".jinja2", + ".j2", + ".txt", + ".md", + ".html", + ".js", + ".css", + ".svg", + ".ico", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".woff", + ".woff2", + ".ttf", + ".map", } BUNDLED_KSADK_CORE_RUNTIME_REQUIREMENTS = ( "a2a-sdk>=0.3.22", @@ -124,15 +125,16 @@ class CodeBuilder(BaseBuilder): "mcp>=1.1.0", "langchain-mcp-adapters>=0.0.1", ) - BUNDLED_KSADK_POSTGRES_SESSION_REQUIREMENTS = ( - "asyncpg>=0.30.0,<1.0.0", - ) + BUNDLED_KSADK_POSTGRES_SESSION_REQUIREMENTS = ("asyncpg>=0.30.0,<1.0.0",) BUNDLED_KSADK_ATTACHMENT_RUNTIME_REQUIREMENTS = ( "pypdf>=6.0.0", "beautifulsoup4>=4.12.0", ) - BUNDLED_KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS = ( - "rapidocr-onnxruntime>=1.2.0", + BUNDLED_KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS = ("rapidocr-onnxruntime>=1.2.0",) + BUNDLED_AGUI_RUNTIME_REQUIREMENTS = ( + "ag-ui-protocol==0.1.19", + "ag-ui-langgraph[fastapi]==0.0.42", + "copilotkit==0.1.94", ) BUNDLED_KSADK_RUNTIME_REQUIREMENTS = ( *BUNDLED_KSADK_CORE_RUNTIME_REQUIREMENTS, @@ -149,8 +151,8 @@ class CodeBuilder(BaseBuilder): PIP_INSTALL_TIMEOUT_SECONDS = 45 * 60 CONTAINER_SUGGESTION_RAW_THRESHOLD_BYTES = 500 * 1024 * 1024 CONTAINER_SUGGESTION_ZIP_THRESHOLD_BYTES = 300 * 1024 * 1024 - - def __init__(self, project_dir: Path, config: dict = None): + + def __init__(self, project_dir: Path, config: Optional[dict] = None): super().__init__(project_dir, config) self.build_dir = self.project_dir / ".agentengine" / "code_build" self.deps_dir = self.build_dir / "linux_deps" @@ -168,33 +170,38 @@ def __init__(self, project_dir: Path, config: dict = None): self._package_progress_logged_milestone_by_label: dict[str, int] = {} self._pip_index_candidates_cache: Optional[list[Optional[str]]] = None self._pip_index_selection_summary = "" - + def build(self) -> BuildResult: """执行 Code 模式构建""" from ksadk.detection import FrameworkDetector - + self._load_dotenv() config = self._load_config() - + # 检测框架 detector = FrameworkDetector(str(self.project_dir)) detection_result = detector.detect() - + if detection_result.type.value == "unknown": + return BuildResult(success=False, error_message="未检测到支持的框架") + if detection_result.type.value == "codex": return BuildResult( success=False, - error_message="未检测到支持的框架" + error_message=( + "Codex 是 ManagedRuntime,不能构建宿主机 Code zip;" + "请使用 `ksadk build .` 的 managed 模式,或显式构建 Container" + ), ) - + click.echo(f"📦 框架: {click.style(detection_result.type.value, fg='green')}") click.echo(f"🤖 Agent: {click.style(detection_result.name, fg='blue')}") - - agent_name = config.get('name', self.project_dir.name).replace('-', '_').replace('.', '_') - + + agent_name = config.get("name", self.project_dir.name).replace("-", "_").replace(".", "_") + # 创建构建目录 self.build_dir.mkdir(parents=True, exist_ok=True) zip_path = self.build_dir / f"{agent_name}.zip" - + # 检查是否需要重新构建 no_cache = self.config.get("no_cache", False) if self.config else False repackage = self.config.get("repackage", False) if self.config else False @@ -207,31 +214,33 @@ def build(self) -> BuildResult: if zip_path.exists() and not rebuild_needed: incompatibles = self._scan_incompatible_binaries_in_zip(zip_path) if incompatibles: - click.secho("\n⚠️ 检测到缓存构建包含非 Linux 兼容关键二进制,自动重建...", fg='yellow') + click.secho( + "\n⚠️ 检测到缓存构建包含非 Linux 兼容关键二进制,自动重建...", fg="yellow" + ) for item in incompatibles[:5]: click.echo(f" - {item}") rebuild_reason = "缓存 zip 存在 Linux 兼容性问题" else: self._save_input_fingerprint(zip_path, detection_result) zip_size = zip_path.stat().st_size / (1024 * 1024) - click.secho(f"\n✅ 使用已有构建: {zip_path.name} ({zip_size:.2f} MB)", fg='green') - click.echo(" (如需只重新打包当前代码/runtime,请使用 --repackage;如需重装依赖,请使用 --no-cache)") + click.secho(f"\n✅ 使用已有构建: {zip_path.name} ({zip_size:.2f} MB)", fg="green") + click.echo( + " (如需只重新打包当前代码/runtime,请使用 --repackage;" + "如需重装依赖,请使用 --no-cache)" + ) return BuildResult( success=True, artifact_path=zip_path, artifact_size=zip_path.stat().st_size, - metadata={ - "agent_name": agent_name, - "framework": detection_result.type.value - } + metadata={"agent_name": agent_name, "framework": detection_result.type.value}, ) elif rebuild_reason: click.echo(f" 重新打包原因: {rebuild_reason}") - + # Step 1: 准备依赖 click.echo("\n📋 Step 1/3: 准备依赖清单...") requirements_path = self._prepare_requirements(detection_result) - + # Step 2: 安装依赖 click.echo("\n📦 Step 2/3: 安装依赖...") reuse_dependencies, reuse_reason = self._can_reuse_dependency_cache( @@ -241,7 +250,8 @@ def build(self) -> BuildResult: if reuse_dependencies: stats = self._current_dependency_stats() click.secho( - f" ✓ 复用 Linux 依赖缓存: {stats['file_count']} 个文件, {stats['size_mb']:.1f} MB", + f" ✓ 复用 Linux 依赖缓存: {stats['file_count']} 个文件, " + f"{stats['size_mb']:.1f} MB", fg="green", ) click.echo(f" {reuse_reason}") @@ -250,25 +260,22 @@ def build(self) -> BuildResult: click.echo(f" {reuse_reason}") self._clear_dependency_cache() self.deps_dir.mkdir(parents=True, exist_ok=True) - + if not self._install_dependencies(requirements_path): - return BuildResult( - success=False, - error_message="依赖安装失败" - ) + return BuildResult(success=False, error_message="依赖安装失败") self._save_dependency_fingerprint(requirements_path) - + # Step 3: 打包 zip click.echo("\n📦 Step 3/3: 打包 zip...") package_started_at = time.monotonic() self._package_zip(zip_path, detection_result) click.echo(f" ✓ 打包耗时: {self._format_elapsed(package_started_at)}") self._save_input_fingerprint(zip_path, detection_result) - + zip_size = zip_path.stat().st_size click.echo(f" zip 文件: {zip_path}") click.echo(f" 大小: {zip_size / (1024 * 1024):.2f} MB") - + return BuildResult( success=True, artifact_path=zip_path, @@ -276,8 +283,8 @@ def build(self) -> BuildResult: metadata={ "agent_name": agent_name, "framework": detection_result.type.value, - "deps_dir": str(self.deps_dir) - } + "deps_dir": str(self.deps_dir), + }, ) def _rebuild_decision( @@ -321,26 +328,32 @@ def _classify_rebuild_reason(self, previous: dict, current: dict) -> str: if changed: return "业务代码或项目文件变更" return "构建输入指纹变化" - + def _need_rebuild(self, zip_path: Path, detection_result) -> bool: """检查是否需要重新构建。优先使用输入内容指纹,缺失时回退到 mtime。""" manifest = self._load_input_fingerprint(zip_path) if manifest: current_fingerprint = self._build_input_fingerprint(detection_result) - return manifest.get("fingerprint") != current_fingerprint["fingerprint"] + return bool(manifest.get("fingerprint") != current_fingerprint["fingerprint"]) return self._need_rebuild_from_mtime(zip_path) def _need_rebuild_from_mtime(self, zip_path: Path) -> bool: """兼容旧缓存:当没有指纹文件时,回退到 mtime 判断。""" zip_mtime = zip_path.stat().st_mtime - + for item in self.project_dir.iterdir(): - if item.name.startswith('.') or item.name in ('__pycache__', 'node_modules', '.git', '.venv', 'venv'): + if item.name.startswith(".") or item.name in ( + "__pycache__", + "node_modules", + ".git", + ".venv", + "venv", + ): continue if item.is_file() and item.stat().st_mtime > zip_mtime: return True if item.is_dir(): - for file_path in item.rglob('*.py'): + for file_path in item.rglob("*.py"): if file_path.stat().st_mtime > zip_mtime: return True return False @@ -360,7 +373,7 @@ def _load_input_fingerprint(self, zip_path: Path) -> Optional[dict]: data = json.load(f) if data.get("version") != self.INPUT_FINGERPRINT_VERSION: return None - return data + return data if isinstance(data, dict) else None except Exception: return None @@ -380,7 +393,7 @@ def _load_dependency_fingerprint(self) -> Optional[dict]: data = json.load(f) if data.get("version") != self.DEPENDENCY_FINGERPRINT_VERSION: return None - return data + return data if isinstance(data, dict) else None except Exception: return None @@ -429,7 +442,12 @@ def _mcp_runtime_enabled(self) -> bool: return True if self._project_env_has_configured_value("KSADK_MCP_SERVERS"): return True - if self._project_env_value("KSADK_ENABLE_MCP_TOOLS").strip().lower() in {"1", "true", "yes", "on"}: + if self._project_env_value("KSADK_ENABLE_MCP_TOOLS").strip().lower() in { + "1", + "true", + "yes", + "on", + }: return True return self._project_imports_any({"mcp", "langchain_mcp_adapters"}) @@ -515,13 +533,17 @@ def _build_dependency_fingerprint(self, requirements_path: Path) -> dict: digest = hashlib.sha256() requirements_text = requirements_path.read_text(encoding="utf-8") - digest.update(f"deps-fingerprint-version:{self.DEPENDENCY_FINGERPRINT_VERSION}\n".encode("utf-8")) + digest.update( + f"deps-fingerprint-version:{self.DEPENDENCY_FINGERPRINT_VERSION}\n".encode("utf-8") + ) digest.update(f"builder-platform:{sys.platform}\n".encode("utf-8")) digest.update( f"builder-python:{sys.version_info.major}.{sys.version_info.minor}\n".encode("utf-8") ) digest.update(f"target-python:{self.TARGET_PYTHON_VERSION}\n".encode("utf-8")) - digest.update(f"target-platforms:{','.join(self.TARGET_INSTALL_PLATFORMS)}\n".encode("utf-8")) + digest.update( + f"target-platforms:{','.join(self.TARGET_INSTALL_PLATFORMS)}\n".encode("utf-8") + ) digest.update(requirements_text.encode("utf-8")) requirements = [line for line in requirements_text.splitlines() if line.strip()] @@ -641,13 +663,13 @@ def _iter_bundled_source_files(self): ) def _iter_bundled_source_package(self, package_name: str, package_root: Path): - for file_path in sorted(package_root.rglob('*')): + for file_path in sorted(package_root.rglob("*")): if not file_path.is_file(): continue relative_path = file_path.relative_to(package_root) if package_name == "ksadk" and self._should_skip_ksadk_relative_path(relative_path): continue - if '__pycache__' in file_path.parts: + if "__pycache__" in file_path.parts: continue suffix = file_path.suffix.lower() if suffix not in self.KSADK_ALLOWED_SUFFIXES: @@ -709,26 +731,26 @@ def _is_real_dotenv_file(file_name: str) -> bool: file_name.startswith(".env.") and file_name not in {".env.example", ".env.sample", ".env.template"} ) - + def _prepare_requirements(self, detection_result) -> Path: """准备 requirements.txt""" final_deps = self._build_requirements_list(detection_result) if (self.project_dir / "requirements.txt").exists(): - click.echo(f" 发现 requirements.txt,正在合并...") + click.echo(" 发现 requirements.txt,正在合并...") else: - click.echo(f" 自动生成依赖清单") - + click.echo(" 自动生成依赖清单") + # 写入构建目录 requirements_path = self.build_dir / "requirements.txt" requirements_path.write_text("\n".join(final_deps)) - + click.echo(f" 共 {len(final_deps)} 个依赖包:") for dep in final_deps[:5]: click.echo(f" • {dep}") if len(final_deps) > 5: click.echo(f" ... 及其他 {len(final_deps) - 5} 个") - + return requirements_path def _build_requirements_list(self, detection_result) -> List[str]: @@ -740,14 +762,35 @@ def _build_requirements_list(self, detection_result) -> List[str]: user_requirements = self.project_dir / "requirements.txt" if user_requirements.exists(): user_content = user_requirements.read_text(encoding="utf-8") + parsed_user_requirements = parse_requirements_text(user_content) + final_deps = merge_requirement_lists( + final_deps, + self._bundled_ksadk_extra_requirements(parsed_user_requirements), + ) user_deps = exclude_requirement_names( - parse_requirements_text(user_content), + parsed_user_requirements, excluded_names={"ksadk"}, ) final_deps = merge_requirement_lists(final_deps, user_deps) return final_deps - + + def _bundled_ksadk_extra_requirements(self, requirements: List[str]) -> tuple[str, ...]: + extras: set[str] = set() + for raw_requirement in requirements: + try: + parsed = Requirement(raw_requirement) + except InvalidRequirement: + continue + if parsed.name.lower().replace("_", "-") != "ksadk": + continue + extras.update(extra.lower() for extra in parsed.extras) + + bundled: list[str] = [] + if "agui" in extras: + bundled.extend(self.BUNDLED_AGUI_RUNTIME_REQUIREMENTS) + return tuple(bundled) + def _get_base_requirements(self, detection_result) -> List[str]: """获取基础依赖列表""" deps = [ @@ -765,12 +808,12 @@ def _get_base_requirements(self, detection_result) -> List[str]: "openinference-instrumentation-langchain>=0.1.0", "langfuse>=2.0.0", ] - + framework = detection_result.type.value - deps += requirements_for_framework(framework) - + deps += code_requirements_for_framework(framework) + return deps - + # 目标 Python 版本 (必须与容器运行时一致) TARGET_PYTHON_VERSION = "312" # 容器中为 Python 3.12 @@ -875,9 +918,7 @@ def _finish_install_progress(self) -> None: def _result_error_summary(self, result: subprocess.CompletedProcess[str] | None) -> str: if result is None: return "unknown" - combined = "\n".join( - part for part in (result.stderr or "", result.stdout or "") if part - ) + combined = "\n".join(part for part in (result.stderr or "", result.stdout or "") if part) for line in reversed(combined.splitlines()): normalized = line.strip() if normalized: @@ -887,9 +928,7 @@ def _result_error_summary(self, result: subprocess.CompletedProcess[str] | None) def _pip_missing_from_result(self, result: subprocess.CompletedProcess[str] | None) -> bool: if result is None: return False - combined = "\n".join( - part for part in (result.stderr or "", result.stdout or "") if part - ) + combined = "\n".join(part for part in (result.stderr or "", result.stdout or "") if part) return "No module named pip" in combined def _bootstrap_pip_if_missing(self, result: subprocess.CompletedProcess[str] | None) -> bool: @@ -952,7 +991,9 @@ def _summarize_pip_progress_line(self, percent: int, stage: str, line: str) -> O if not normalized: return None - force_emit = percent > self._install_progress_percent or stage != self._install_progress_stage_name + force_emit = ( + percent > self._install_progress_percent or stage != self._install_progress_stage_name + ) count = self._install_progress_event_counts.get(stage, 0) + 1 self._install_progress_event_counts[stage] = count @@ -960,7 +1001,7 @@ def _summarize_pip_progress_line(self, percent: int, stage: str, line: str) -> O return self._truncate_progress_summary(normalized) if stage == "解析依赖": - detail = normalized[len("Collecting "):].split(" (from ", 1)[0] + detail = normalized[len("Collecting ") :].split(" (from ", 1)[0] summary = f"已解析 {count} 个依赖,最近: {detail}" if force_emit or count in self.INSTALL_PROGRESS_EVENT_MILESTONES or count % 10 == 0: return summary @@ -973,35 +1014,36 @@ def _summarize_pip_progress_line(self, percent: int, stage: str, line: str) -> O if stage == "下载依赖": if normalized.startswith("Using cached "): - payload = normalized[len("Using cached "):] + payload = normalized[len("Using cached ") :] elif normalized.startswith("Downloading "): - payload = normalized[len("Downloading "):] + payload = normalized[len("Downloading ") :] else: payload = normalized target = self._extract_pip_artifact_name(payload) - summary = f"已处理 {count} 个 wheel,耗时 {self._format_elapsed(self._install_progress_started_at)},最近: {target}" + elapsed = self._format_elapsed(self._install_progress_started_at) + summary = f"已处理 {count} 个 wheel,耗时 {elapsed},最近: {target}" if force_emit or count in self.INSTALL_PROGRESS_EVENT_MILESTONES or count % 5 == 0: return summary return None if stage == "构建 wheel": - detail = normalized[len("Building wheel for "):].split(" ", 1)[0] + detail = normalized[len("Building wheel for ") :].split(" ", 1)[0] if force_emit or count == 1 or count % 5 == 0: return f"构建 wheel: {detail}" return None if stage == "安装依赖": - detail = normalized[len("Installing collected packages:"):].strip() + detail = normalized[len("Installing collected packages:") :].strip() return f"安装包: {self._truncate_progress_summary(detail, max_length=60)}" if stage == "复用已安装依赖": - detail = normalized[len("Requirement already satisfied:"):].split(" in ", 1)[0].strip() + detail = normalized[len("Requirement already satisfied:") :].split(" in ", 1)[0].strip() if force_emit or count in self.INSTALL_PROGRESS_EVENT_MILESTONES or count % 10 == 0: return f"复用依赖: {detail}" return None if stage == "安装完成": - detail = normalized[len("Successfully installed "):].strip() + detail = normalized[len("Successfully installed ") :].strip() return f"最近结果: {self._truncate_progress_summary(detail, max_length=60)}" return self._truncate_progress_summary(normalized) @@ -1094,8 +1136,7 @@ def _rank_pip_index_urls(self, urls: list[str]) -> tuple[list[str], str]: timings: dict[str, Optional[float]] = {} with ThreadPoolExecutor(max_workers=min(4, len(urls))) as executor: future_to_url = { - executor.submit(self._probe_pip_index_latency, url): url - for url in urls + executor.submit(self._probe_pip_index_latency, url): url for url in urls } for future in as_completed(future_to_url): url = future_to_url[future] @@ -1116,7 +1157,7 @@ def _rank_pip_index_urls(self, urls: list[str]) -> tuple[list[str], str]: best = ordered[0] best_ms = round((timings[best] or 0.0) * 1000) return ordered, f"测速优先 {self._short_pip_index_label(best)} ({best_ms} ms)" - + def _install_dependencies(self, requirements_path: Path) -> bool: """安装依赖到 deps_dir""" current_python = f"{sys.version_info.major}.{sys.version_info.minor}" @@ -1128,9 +1169,8 @@ def _install_dependencies(self, requirements_path: Path) -> bool: ) try: - need_binary_replacement = ( - sys.platform in ("darwin", "win32") - or (sys.platform.startswith("linux") and current_python != target_python) + need_binary_replacement = sys.platform in ("darwin", "win32") or ( + sys.platform.startswith("linux") and current_python != target_python ) used_target_runtime_wheels = False result = None @@ -1167,16 +1207,18 @@ def _install_dependencies(self, requirements_path: Path) -> bool: requirements_path, target_runtime_wheels=False, ) - + if result.returncode != 0: self._finish_install_progress() - click.secho(" ✗ 安装失败", fg='red') + click.secho(" ✗ 安装失败", fg="red") if result.stderr: - error_lines = [l for l in result.stderr.split('\n') if 'ERROR' in l.upper()][:3] + error_lines = [ + line for line in result.stderr.split("\n") if "ERROR" in line.upper() + ][:3] for line in error_lines: click.echo(f" {line}") return False - + # 替换非 Linux 平台二进制,或 Linux 下非目标 Python ABI 的二进制 if need_binary_replacement and not used_target_runtime_wheels: self._emit_install_progress( @@ -1185,7 +1227,7 @@ def _install_dependencies(self, requirements_path: Path) -> bool: "检测并替换平台相关二进制", ) self._replace_platform_binaries() - + # 二进制兼容性校验(避免把不可运行的包部署到 Linux Runtime) self._emit_install_progress( 96, @@ -1195,7 +1237,7 @@ def _install_dependencies(self, requirements_path: Path) -> bool: incompatibles = self._scan_incompatible_binaries_in_deps() if incompatibles: self._finish_install_progress() - click.secho(" ✗ 检测到与 Linux Runtime 不兼容的关键二进制,构建终止", fg='red') + click.secho(" ✗ 检测到与 Linux Runtime 不兼容的关键二进制,构建终止", fg="red") for item in incompatibles[:10]: click.echo(f" - {item}") if any(i.startswith("python-abi-mismatch:") for i in incompatibles): @@ -1204,213 +1246,243 @@ def _install_dependencies(self, requirements_path: Path) -> bool: f"请使用 Python {target_python} 构建,或改用 Container 模式部署" ) if any("tiktoken" in i for i in incompatibles): - click.echo(" 提示: tiktoken 为 langchain-openai 必需;若替换失败可重试或检查网络/镜像") + click.echo( + " 提示: tiktoken 为 langchain-openai 必需;" + "若替换失败可重试或检查网络/镜像" + ) click.echo(" 建议: 删除 .agentengine/*_build 后重新构建,或在 Linux 环境重新打包") return False - - deps_count = sum(1 for _ in self.deps_dir.rglob('*') if _.is_file()) - deps_size = sum(f.stat().st_size for f in self.deps_dir.rglob('*') if f.is_file()) / (1024 * 1024) + + deps_count = sum(1 for _ in self.deps_dir.rglob("*") if _.is_file()) + deps_size = sum(f.stat().st_size for f in self.deps_dir.rglob("*") if f.is_file()) / ( + 1024 * 1024 + ) self._emit_install_progress( 100, "依赖安装完成", f"{deps_count} 个文件, {deps_size:.1f} MB", ) self._finish_install_progress() - + return True - + except subprocess.TimeoutExpired: self._finish_install_progress() timeout_minutes = max(1, round(self._pip_install_timeout_seconds() / 60)) - click.secho(f" ✗ 安装超时 ({timeout_minutes}分钟)", fg='red') + click.secho(f" ✗ 安装超时 ({timeout_minutes}分钟)", fg="red") return False except Exception as e: self._finish_install_progress() - click.secho(f" ✗ 依赖安装失败: {e}", fg='red') + click.secho(f" ✗ 依赖安装失败: {e}", fg="red") return False - + def _replace_platform_binaries(self) -> None: """替换非目标运行时平台/ABI 的 C 扩展为 Linux 目标版本。""" # 模块名到 pip 包名的映射 MODULE_TO_PACKAGE = { - '_cffi_backend': 'cffi', - 'yaml': 'pyyaml', - '_yaml': 'pyyaml', - 'rpds': 'rpds-py', - 'PIL': 'pillow', - 'cv2': 'opencv-python', - 'sklearn': 'scikit-learn', - '_watchdog_fsevents': 'watchdog', - 'google': None, # 跳过命名空间包 - 'grpc': 'grpcio', - '_grpc': 'grpcio', - 'uuid_utils': 'uuid-utils', - 'pydantic_core': 'pydantic-core', - '_pydantic_core': 'pydantic-core', + "_cffi_backend": "cffi", + "yaml": "pyyaml", + "_yaml": "pyyaml", + "rpds": "rpds-py", + "PIL": "pillow", + "cv2": "opencv-python", + "sklearn": "scikit-learn", + "_watchdog_fsevents": "watchdog", + "google": None, # 跳过命名空间包 + "grpc": "grpcio", + "_grpc": "grpcio", + "uuid_utils": "uuid-utils", + "pydantic_core": "pydantic-core", + "_pydantic_core": "pydantic-core", # tiktoken: langchain-openai 核心依赖, Rust 编译的 C 扩展 - 'tiktoken': 'tiktoken', - '_tiktoken': 'tiktoken', + "tiktoken": "tiktoken", + "_tiktoken": "tiktoken", # 其他常见原生扩展 - 'regex': 'regex', - '_regex': 'regex', - 'multidict': 'multidict', - 'yarl': 'yarl', - 'aiohttp': 'aiohttp', - 'frozenlist': 'frozenlist', - 'charset_normalizer': 'charset-normalizer', - 'msgpack': 'msgpack', + "regex": "regex", + "_regex": "regex", + "multidict": "multidict", + "yarl": "yarl", + "aiohttp": "aiohttp", + "frozenlist": "frozenlist", + "charset_normalizer": "charset-normalizer", + "msgpack": "msgpack", # Windows 特有 - 'win32': 'pywin32', - 'win32com': 'pywin32', + "win32": "pywin32", + "win32com": "pywin32", } - + # 找到所有二进制文件 binary_files = [] - if sys.platform == 'darwin': - binary_files = list(self.deps_dir.rglob('*.so')) + list(self.deps_dir.rglob('*.dylib')) - elif sys.platform == 'win32': - binary_files = list(self.deps_dir.rglob('*.pyd')) + list(self.deps_dir.rglob('*.dll')) - elif sys.platform.startswith('linux'): - binary_files = list(self.deps_dir.rglob('*.so')) - + if sys.platform == "darwin": + binary_files = list(self.deps_dir.rglob("*.so")) + list(self.deps_dir.rglob("*.dylib")) + elif sys.platform == "win32": + binary_files = list(self.deps_dir.rglob("*.pyd")) + list(self.deps_dir.rglob("*.dll")) + elif sys.platform.startswith("linux"): + binary_files = list(self.deps_dir.rglob("*.so")) + if not binary_files: return - + # 提取需要替换的包名 packages_to_replace: Set[str] = set() for bin_file in binary_files: rel_path = bin_file.relative_to(self.deps_dir) parts = rel_path.parts - + # 忽略 bin 目录下的 dll (通常是 runtime) - if 'bin' in parts: + if "bin" in parts: continue - + if len(parts) > 1: detected_name = parts[0] else: - detected_name = bin_file.name.split('.')[0] - + detected_name = bin_file.name.split(".")[0] + # 跳过特定文件夹 - if detected_name in ('__pycache__', 'bin', 'include', 'lib', 'Scripts'): + if detected_name in ("__pycache__", "bin", "include", "lib", "Scripts"): continue - + if detected_name in MODULE_TO_PACKAGE: pkg_name = MODULE_TO_PACKAGE[detected_name] if pkg_name: packages_to_replace.add(pkg_name) else: packages_to_replace.add(detected_name) - + if not packages_to_replace: return - - click.echo(f"\r 检测到 {len(binary_files)} 个二进制文件 ({sys.platform}), 替换 {len(packages_to_replace)} 个包为 Linux 版本") - + + click.echo( + f"\r 检测到 {len(binary_files)} 个二进制文件 ({sys.platform}), " + f"替换 {len(packages_to_replace)} 个包为 Linux 版本" + ) + # 下载 Linux wheels wheels_dir = self.build_dir / "linux_wheels" if wheels_dir.exists(): shutil.rmtree(wheels_dir) wheels_dir.mkdir(parents=True) - + replaced_count = 0 total_packages = len(packages_to_replace) for index, pkg_name in enumerate(sorted(packages_to_replace), start=1): # 提取确切的版本号以避免不兼容问题 (如 pydantic-core) target_version = "" - search_name = pkg_name.replace('-', '_').lower() + search_name = pkg_name.replace("-", "_").lower() for info_dir in self.deps_dir.glob(f"{search_name}-*.dist-info"): - version_str = info_dir.name[len(search_name)+1:-10] # remove search_name + '-' and '.dist-info' + version_str = info_dir.name[ + len(search_name) + 1 : -10 + ] # remove search_name + '-' and '.dist-info' target_version = f"=={version_str}" break - + pkg_with_version = f"{pkg_name}{target_version}" self._emit_install_progress( 88 + min(6, round((index / max(total_packages, 1)) * 6)), "替换 Linux wheels", f"{index}/{total_packages} {pkg_with_version}", ) - + try: downloaded = False for index_url in self._pip_index_candidates(): download_cmd = [ - sys.executable, "-m", "pip", "download", + sys.executable, + "-m", + "pip", + "download", pkg_with_version, - "-d", str(wheels_dir), - "--platform", "manylinux2014_x86_64", - "--platform", "manylinux_2_17_x86_64", - "--platform", "manylinux_2_28_x86_64", - "--platform", "musllinux_1_2_x86_64", - "--platform", "linux_x86_64", - "--python-version", self.TARGET_PYTHON_VERSION, + "-d", + str(wheels_dir), + "--platform", + "manylinux2014_x86_64", + "--platform", + "manylinux_2_17_x86_64", + "--platform", + "manylinux_2_28_x86_64", + "--platform", + "musllinux_1_2_x86_64", + "--platform", + "linux_x86_64", + "--python-version", + self.TARGET_PYTHON_VERSION, "--only-binary=:all:", - "--implementation", "cp", + "--implementation", + "cp", "--no-deps", "--quiet", "--disable-pip-version-check", - "--retries", "2", - "--timeout", "30", - "--cache-dir", str(self.pip_cache_dir), + "--retries", + "2", + "--timeout", + "30", + "--cache-dir", + str(self.pip_cache_dir), ] if index_url: download_cmd += ["-i", index_url] - result = subprocess.run(download_cmd, capture_output=True, text=True, timeout=90) + result = subprocess.run( + download_cmd, capture_output=True, text=True, timeout=90 + ) if result.returncode == 0: downloaded = True break if downloaded: replaced_count += 1 else: - failed_msg = result.stderr.strip().split('\n')[-1] if result and result.stderr else 'unknown' - click.secho(f" ⚠ 替换失败: {pkg_with_version} ({failed_msg})", fg='yellow') + failed_msg = ( + result.stderr.strip().split("\n")[-1] + if result and result.stderr + else "unknown" + ) + click.secho(f" ⚠ 替换失败: {pkg_with_version} ({failed_msg})", fg="yellow") except Exception as e: - click.secho(f" ⚠ 替换异常: {pkg_name} ({e})", fg='yellow') - + click.secho(f" ⚠ 替换异常: {pkg_name} ({e})", fg="yellow") + # 解压 wheel 覆盖到 deps_dir for wheel_file in wheels_dir.glob("*.whl"): try: - wheel_name = wheel_file.name.split('-')[0].lower().replace('_', '-') - + wheel_name = wheel_file.name.split("-")[0].lower().replace("_", "-") + # 删除旧的包目录 for old_dir in self.deps_dir.iterdir(): - if old_dir.is_dir() and old_dir.name.lower().replace('_', '-') == wheel_name: + if old_dir.is_dir() and old_dir.name.lower().replace("_", "-") == wheel_name: shutil.rmtree(old_dir) - # 不要 break,可能由多个目录 (e.g. pydantic_core, pydantic_core-2.x.dist-info) - + # 不要 break:一个包可能有多个目录,例如 dist-info。 + # 删除根目录下的二进制文件 - for ext in ('*.so', '*.dylib', '*.pyd', '*.dll'): + for ext in ("*.so", "*.dylib", "*.pyd", "*.dll"): for bin_file in self.deps_dir.glob(f"{wheel_name}*{ext[1:]}"): try: bin_file.unlink() - except: + except OSError: pass for bin_file in self.deps_dir.glob(f"{wheel_name.replace('-', '_')}*{ext[1:]}"): try: bin_file.unlink() - except: + except OSError: pass - + # 解压新的 wheel - with zipfile.ZipFile(wheel_file, 'r') as zf: + with zipfile.ZipFile(wheel_file, "r") as zf: zf.extractall(self.deps_dir) except Exception: pass - + shutil.rmtree(wheels_dir, ignore_errors=True) - + # 清理所有残留的非 Linux 平台二进制文件 # (wheel 解压后可能有旧的 darwin/win .so 文件未被覆盖) cleaned_count = 0 - for so_file in list(self.deps_dir.rglob('*.so')): + for so_file in list(self.deps_dir.rglob("*.so")): name = so_file.name.lower() - if 'darwin' in name or 'win' in name: + if "darwin" in name or "win" in name: try: so_file.unlink() cleaned_count += 1 except Exception: pass - for dylib_file in list(self.deps_dir.rglob('*.dylib')): + for dylib_file in list(self.deps_dir.rglob("*.dylib")): try: dylib_file.unlink() cleaned_count += 1 @@ -1418,7 +1490,7 @@ def _replace_platform_binaries(self) -> None: pass if cleaned_count > 0: click.echo(f" ✓ 清理 {cleaned_count} 个残留平台二进制文件") - + click.echo(f" ✓ 成功替换 {replaced_count}/{len(packages_to_replace)} 个包") def _is_linux_so(self, name: str) -> bool: @@ -1463,7 +1535,7 @@ def _scan_incompatible_binaries_in_deps(self) -> List[str]: def _detect_critical_binary_issues(self, names: List[str]) -> List[str]: issues: List[str] = [] - + # 定义需要检查的关键原生扩展模块 # 格式: (描述, 正则模式) critical_modules = [ @@ -1474,7 +1546,7 @@ def _detect_critical_binary_issues(self, names: List[str]) -> List[str]: # tiktoken/_tiktoken: langchain-openai 核心依赖 (Rust 编译) ("tiktoken/_tiktoken", r"tiktoken/_tiktoken.*\.(so|pyd)$"), ] - + for module_name, pattern in critical_modules: matched_bins = [n for n in names if re.search(pattern, n)] if matched_bins: @@ -1487,19 +1559,21 @@ def _detect_critical_binary_issues(self, names: List[str]) -> List[str]: f"python-abi-mismatch:{module_name}:" f"expected-cpython-{self.TARGET_PYTHON_VERSION}-or-abi3" ) - + # 通用检查: 所有 .so 文件中不应包含 darwin/win 平台标识 - all_so_files = [n for n in names if n.endswith('.so')] - darwin_so_count = sum(1 for n in all_so_files if 'darwin' in n.lower()) + all_so_files = [n for n in names if n.endswith(".so")] + darwin_so_count = sum(1 for n in all_so_files if "darwin" in n.lower()) if darwin_so_count > 0: issues.append(f"warning:found-{darwin_so_count}-darwin-so-files") - + return issues - + def _package_zip(self, zip_path: Path, detection_result) -> None: """打包 zip 文件""" project_files = list(self._iter_project_files()) - dependency_files = [file_path for file_path in self.deps_dir.rglob("*") if file_path.is_file()] + dependency_files = [ + file_path for file_path in self.deps_dir.rglob("*") if file_path.is_file() + ] bundled_source_files = list(self._iter_bundled_source_files()) dependency_size = sum(file_path.stat().st_size for file_path in dependency_files) if dependency_files: @@ -1509,12 +1583,12 @@ def _package_zip(self, zip_path: Path, detection_result) -> None: f"{len(bundled_source_files)} 个 runtime 文件" ) - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: # 添加项目文件 for file_path in project_files: arcname = file_path.relative_to(self.project_dir).as_posix() zf.write(file_path, arcname) - + # 添加依赖 deps_count = 0 for file_path in dependency_files: @@ -1523,7 +1597,7 @@ def _package_zip(self, zip_path: Path, detection_result) -> None: deps_count += 1 self._emit_package_progress("打包依赖", deps_count, len(dependency_files)) self._finish_package_progress() - + # 添加随运行时下发的 ksadk 源码 bundled_source_count = 0 for package_name, relative, file_path in bundled_source_files: @@ -1536,13 +1610,13 @@ def _package_zip(self, zip_path: Path, detection_result) -> None: len(bundled_source_files), ) self._finish_package_progress() - + click.echo(f" ✓ 打包运行时源码: {bundled_source_count} 个文件") - + # 添加 entrypoint entrypoint_content = self._generate_entrypoint(detection_result) zf.writestr("entrypoint.py", entrypoint_content) - + click.echo(f" ✓ 打包完成: {len(project_files)} 个项目文件 + {deps_count} 个依赖文件") self._emit_package_size_report(zip_path) @@ -1586,10 +1660,7 @@ def _emit_package_size_report_from_entries( ) if by_top_level: top_items = sorted(by_top_level.items(), key=lambda item: item[1], reverse=True)[:limit] - summary = ", ".join( - f"{name} {size / (1024 * 1024):.1f} MB" - for name, size in top_items - ) + summary = ", ".join(f"{name} {size / (1024 * 1024):.1f} MB" for name, size in top_items) click.echo(f" 体积 Top{len(top_items)}: {summary}") if ( raw_total > self.CONTAINER_SUGGESTION_RAW_THRESHOLD_BYTES @@ -1660,7 +1731,7 @@ def _finish_package_progress(self) -> None: self._package_progress_last_line = "" self._package_progress_last_percent_by_label = {} self._package_progress_logged_milestone_by_label = {} - + def _generate_entrypoint(self, detection_result) -> str: """生成 entrypoint.py""" package_name = Path(detection_result.package_path).name @@ -1787,7 +1858,10 @@ def _generate_entrypoint(self, detection_result) -> str: logger.info(f"入口: {{detection_result.entry_point}}") # 初始化 Tracing (如果配置了 Langfuse、标准 OTLP HTTP 或 CloudMonitor OTLP) -has_otlp = bool(os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")) +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") or os.environ.get("CLOUD_MONITOR_OTLP_ENDPOINT") @@ -1798,11 +1872,19 @@ def _generate_entrypoint(self, detection_result) -> str: 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 ( + os.environ.get("LANGFUSE_PUBLIC_KEY") + or has_otlp + or has_cloud_monitor_otlp + or has_cloud_monitor_langfuse +): try: from ksadk.tracing import setup_tracing - - use_callback_only = os.environ.get("LANGFUSE_USE_CALLBACK", "").strip().lower() in ("1", "true", "yes", "on") + + use_callback_only = ( + os.environ.get("LANGFUSE_USE_CALLBACK", "").strip().lower() + in ("1", "true", "yes", "on") + ) setup_tracing(use_callback_only=use_callback_only) logger.info( @@ -1818,7 +1900,7 @@ def _generate_entrypoint(self, detection_result) -> str: logger.info("正在加载 Agent...") runner = create_runner(detection_result, CODE_ROOT) runner.load_agent() -set_runner(runner) +set_runner(runner, loaded=True) logger.info("Agent 加载成功!") if __name__ == "__main__": @@ -1848,13 +1930,13 @@ def _pip_index_candidates(self) -> List[Optional[str]]: self._pip_index_candidates_cache = candidates return list(candidates) - seen: Set[str] = set() + fallback_seen: Set[str] = set() urls: list[str] = [] for candidate in self.PIP_INDEX_FALLBACKS: normalized = self._normalize_pip_index_url(candidate) - if normalized in seen: + if normalized in fallback_seen: continue - seen.add(normalized) + fallback_seen.add(normalized) urls.append(normalized) ordered, summary = self._rank_pip_index_urls(urls) diff --git a/ksadk/builders/container_builder.py b/ksadk/builders/container_builder.py index 23305f75..7c70a032 100644 --- a/ksadk/builders/container_builder.py +++ b/ksadk/builders/container_builder.py @@ -3,13 +3,12 @@ """ import os -import subprocess +import platform import shutil +import subprocess import time -import platform -import asyncio from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional import click @@ -25,12 +24,15 @@ parse_requirements_text, ) +if TYPE_CHECKING: + from ksadk.deployment.base import PackageInfo + def _registry_host(registry: str | None) -> str: value = str(registry or "").strip() for prefix in ("http://", "https://"): if value.startswith(prefix): - value = value[len(prefix):] + value = value[len(prefix) :] break return value.split("/", 1)[0].strip() @@ -100,8 +102,8 @@ def print_registry_credentials_help(registry: str | None, *, kind: str | None = def ensure_docker_running() -> bool: """确保 Docker 正在运行""" - if not shutil.which('docker'): - click.secho("❌ 未找到 docker 命令", fg='red') + if not shutil.which("docker"): + click.secho("❌ 未找到 docker 命令", fg="red") click.echo("") click.echo("请先安装 Docker:") if platform.system() == "Darwin": @@ -113,36 +115,36 @@ def ensure_docker_running() -> bool: else: click.echo(" • 下载 Docker Desktop: https://www.docker.com/products/docker-desktop/") return False - + try: - result = subprocess.run(['docker', 'info'], capture_output=True, timeout=10) + result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) if result.returncode == 0: return True - except: + except (OSError, subprocess.SubprocessError): pass - - click.secho("⚠️ Docker daemon 未运行", fg='yellow') - + + click.secho("⚠️ Docker daemon 未运行", fg="yellow") + system_name = platform.system() if system_name == "Darwin": click.echo("🚀 正在启动 Docker Desktop...") try: - subprocess.run(['open', '-a', 'Docker'], check=True) + subprocess.run(["open", "-a", "Docker"], check=True) for i in range(60): time.sleep(1) try: - result = subprocess.run(['docker', 'info'], capture_output=True, timeout=5) + result = subprocess.run(["docker", "info"], capture_output=True, timeout=5) if result.returncode == 0: - click.secho("✅ Docker Desktop 已启动", fg='green') + click.secho("✅ Docker Desktop 已启动", fg="green") return True - except: + except (OSError, subprocess.SubprocessError): pass if i % 5 == 0 and i > 0: click.echo(f" 等待 Docker 启动中... ({i}秒)") - click.secho("❌ Docker Desktop 启动超时", fg='red') + click.secho("❌ Docker Desktop 启动超时", fg="red") return False - except: - click.secho("❌ 无法启动 Docker Desktop", fg='red') + except (OSError, subprocess.SubprocessError): + click.secho("❌ 无法启动 Docker Desktop", fg="red") return False elif system_name == "Windows": click.echo("请启动 Docker Desktop,然后重试当前命令。") @@ -154,81 +156,95 @@ def ensure_docker_running() -> bool: class ContainerBuilder(BaseBuilder): """Docker 镜像构建器""" - - def __init__(self, project_dir: Path, config: dict = None, - tag: str = None, registry: str = None, no_cache: bool = False): + + def __init__( + self, + project_dir: Path, + config: Optional[dict] = None, + tag: Optional[str] = None, + registry: Optional[str] = None, + no_cache: bool = False, + ): super().__init__(project_dir, config) self.tag = tag self.registry = registry self.no_cache = no_cache - + def _get_smart_kcr_endpoint(self, region: str) -> str: """智能选择 KCR endpoint - + 使用企业版 KCR (hub.kce.ksyun.com/agentengine/)。 优先使用内网 VPC endpoint,如果内网不可达则使用公网。 """ from ksadk.configs.settings import check_endpoint_reachable - + # 企业版 KCR 地址 (带 agentengine 命名空间) vpc_endpoint = "hub-vpc.kce.ksyun.com/agentengine" public_endpoint = "hub.kce.ksyun.com/agentengine" - - click.echo(f"🔍 检测 KCR 内网连通性...") - + + click.echo("🔍 检测 KCR 内网连通性...") + # 检测 VPC 内网是否可达 (端口 443 for HTTPS registry) if check_endpoint_reachable("hub-vpc.kce.ksyun.com", port=443, timeout=2.0): - click.secho(f" ✅ 使用内网: {vpc_endpoint}", fg='green') + click.secho(f" ✅ 使用内网: {vpc_endpoint}", fg="green") return vpc_endpoint else: click.echo(f" ℹ️ 使用公网: {public_endpoint}") return public_endpoint - + def _optimize_kcr_endpoint(self, registry: str) -> str: """优化 KCR endpoint - + 如果是金山云 KCR 公网地址,且内网可达,则替换为内网地址。 """ from ksadk.configs.settings import check_endpoint_reachable - + + registry_text = str(registry).strip() + scheme, separator, reference = registry_text.partition("://") + prefix = f"{scheme}://" if separator else "" + target = reference if separator else registry_text + host, slash, remainder = target.partition("/") + normalized_host = host.lower() + # 已经是内网地址 - if 'hub-vpc' in registry: + if normalized_host == "hub-vpc.kce.ksyun.com": return registry - - # 匹配企业版 KCR: hub.kce.ksyun.com - if 'hub.kce.ksyun.com' in registry: - click.echo(f"🔍 检测 KCR 内网连通性...") + + # 仅匹配完整 registry host,避免改写路径或攻击者域名中的同名子串。 + if normalized_host == "hub.kce.ksyun.com": + click.echo("🔍 检测 KCR 内网连通性...") if check_endpoint_reachable("hub-vpc.kce.ksyun.com", port=443, timeout=2.0): - optimized = registry.replace("hub.kce.ksyun.com", "hub-vpc.kce.ksyun.com") - click.secho(f" ✅ 优化为内网: {optimized}", fg='green') + optimized = f"{prefix}hub-vpc.kce.ksyun.com{slash}{remainder}" + click.secho(f" ✅ 优化为内网: {optimized}", fg="green") return optimized else: click.echo(f" ℹ️ 使用公网: {registry}") - + return registry - - def _package(self, detection_result) -> 'PackageInfo': + + def _package(self, detection_result) -> "PackageInfo": """打包项目 - 复制文件并生成 Dockerfile/requirements/entrypoint""" from ksadk.deployment.base import PackageInfo - + project_path = self.project_dir output_dir = project_path / ".agentengine" / "build" output_dir.mkdir(parents=True, exist_ok=True) - + package_name = Path(detection_result.package_path).name is_container_first_template = ( getattr(getattr(detection_result, "type", None), "value", "") == "hermes" and (project_path / "Dockerfile").exists() and (project_path / "entrypoint.sh").exists() ) - + # 复制项目文件。真实 .env 只通过 deploy payload 注入,不进入镜像上下文。 for item in project_path.iterdir(): if CodeBuilder._is_real_dotenv_file(item.name): continue - if ( - (item.name.startswith('.') and item.name != '.env.example') - or item.name in ('__pycache__', '.git', 'node_modules') + if (item.name.startswith(".") and item.name != ".env.example") or item.name in ( + "__pycache__", + ".git", + "node_modules", ): continue dest = output_dir / item.name @@ -249,9 +265,10 @@ def _package(self, detection_result) -> 'PackageInfo': ) else: shutil.copy2(item, dest) - + # 复制 ksadk 源码 (确保容器内可用) import ksadk + ksadk_src = Path(ksadk.__file__).parent ksadk_dest = output_dir / "ksadk" if ksadk_dest.exists(): @@ -274,14 +291,15 @@ def _ignore_ksadk_source(current_dir: str, names: list[str]): return ignored shutil.copytree( - ksadk_src, - ksadk_dest, + ksadk_src, + ksadk_dest, ignore=_ignore_ksadk_source, ) # 复制 ksadk_runtime_common (如果存在) try: import ksadk_runtime_common + runtime_common_src = Path(ksadk_runtime_common.__file__).parent runtime_common_dest = output_dir / "ksadk_runtime_common" if runtime_common_dest.exists(): @@ -305,21 +323,22 @@ def _ignore_ksadk_source(current_dir: str, names: list[str]): ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), ) else: - click.echo(click.style( - "⚠️ 警告: 未找到 ksadk_runtime_common,镜像可能无法正常运行", - fg="yellow" - )) - + click.echo( + click.style( + "⚠️ 警告: 未找到 ksadk_runtime_common,镜像可能无法正常运行", fg="yellow" + ) + ) + dockerfile_path = output_dir / "Dockerfile" if not is_container_first_template: dockerfile = self._generate_dockerfile(detection_result) dockerfile_path.write_text(dockerfile) - + # 生成 requirements.txt (合并用户依赖) requirements = self._generate_requirements(detection_result, project_path) requirements_path = output_dir / "requirements.txt" requirements_path.write_text(requirements) - + # 生成启动脚本 if is_container_first_template: entrypoint_path = output_dir / "entrypoint.sh" @@ -327,7 +346,7 @@ def _ignore_ksadk_source(current_dir: str, names: list[str]): entrypoint = self._generate_entrypoint(detection_result, package_name) entrypoint_path = output_dir / "entrypoint.py" entrypoint_path.write_text(entrypoint) - + return PackageInfo( name=detection_result.name or project_path.name, framework=detection_result.type.value, @@ -339,14 +358,14 @@ def _ignore_ksadk_source(current_dir: str, names: list[str]): "package_name": package_name, "requirements": str(requirements_path), "entrypoint": str(entrypoint_path), - } + }, ) - + def _generate_dockerfile(self, detection_result) -> str: """生成优化的 Dockerfile""" base_image = "python:3.12-slim" - - return f'''FROM {base_image} + + return f"""FROM {base_image} # 设置环境变量 ENV PYTHONUNBUFFERED=1 \\ @@ -373,9 +392,9 @@ def _generate_dockerfile(self, detection_result) -> str: # 使用 exec 形式确保信号正确传递 CMD ["python", "entrypoint.py"] -''' - - def _generate_requirements(self, detection_result, project_path: Path = None) -> str: +""" + + def _generate_requirements(self, detection_result, project_path: Optional[Path] = None) -> str: """生成 requirements.txt""" base_deps = [ # Core @@ -392,7 +411,7 @@ def _generate_requirements(self, detection_result, project_path: Path = None) -> "openinference-instrumentation-langchain>=0.1.0", "langfuse>=2.0.0", ] - + framework = detection_result.type.value base_deps += requirements_for_framework(framework) @@ -400,20 +419,26 @@ def _generate_requirements(self, detection_result, project_path: Path = None) -> base_deps, CodeBuilder(project_path or self.project_dir)._bundled_runtime_requirements(), ) - + # 合并用户 requirements.txt (如果存在) if project_path: user_requirements = project_path / "requirements.txt" if user_requirements.exists(): user_content = user_requirements.read_text() + parsed_user_requirements = parse_requirements_text(user_content) + bundled_builder = CodeBuilder(project_path or self.project_dir) + base_deps = merge_requirement_lists( + base_deps, + bundled_builder._bundled_ksadk_extra_requirements(parsed_user_requirements), + ) user_deps = exclude_requirement_names( - parse_requirements_text(user_content), + parsed_user_requirements, excluded_names={"ksadk"}, ) base_deps = merge_requirement_lists(base_deps, user_deps) - + return "\n".join(base_deps) - + def _generate_entrypoint(self, detection_result, package_name: str) -> str: """生成 entrypoint.py""" return f'''""" @@ -504,14 +529,17 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: entry_point="{detection_result.entry_point}", package_path="/app/{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) -has_otlp = bool(os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")) +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") or os.environ.get("CLOUD_MONITOR_OTLP_ENDPOINT") @@ -522,10 +550,18 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: 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 ( + os.environ.get("LANGFUSE_PUBLIC_KEY") + or has_otlp + or has_cloud_monitor_otlp + or has_cloud_monitor_langfuse +): try: from ksadk.tracing import setup_tracing - use_callback_only = os.environ.get("LANGFUSE_USE_CALLBACK", "").strip().lower() in ("1", "true", "yes", "on") + use_callback_only = ( + os.environ.get("LANGFUSE_USE_CALLBACK", "").strip().lower() + in ("1", "true", "yes", "on") + ) setup_tracing(use_callback_only=use_callback_only) logger.info( f"Tracing 已启用 (OTLP={{has_otlp}}, " @@ -540,7 +576,7 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: logger.info("正在加载 Agent...") runner = create_runner(detection_result, "/app") runner.load_agent() -set_runner(runner) +set_runner(runner, loaded=True) logger.info("Agent 加载成功!") if __name__ == "__main__": @@ -548,77 +584,69 @@ def _generate_entrypoint(self, detection_result, package_name: str) -> str: logger.info(f"启动 HTTP Server: 0.0.0.0:{{port}}") uvicorn.run(app, host="0.0.0.0", port=port, log_level=LOG_LEVEL.lower()) ''' - + def build(self) -> BuildResult: """构建 Docker 镜像""" from ksadk.detection import FrameworkDetector - + self._load_dotenv() config = self._load_config() - + # 检测框架 detector = FrameworkDetector(str(self.project_dir)) result = detector.detect() - + if result.type.value == "unknown": - return BuildResult( - success=False, - error_message="未检测到支持的框架" - ) - + return BuildResult(success=False, error_message="未检测到支持的框架") + click.echo(f"📦 框架: {click.style(result.name, fg='green')}") - + # 确定镜像名称 - image_name = config.get('name', self.project_dir.name).replace('-', '_').replace('.', '_') - + image_name = config.get("name", self.project_dir.name).replace("-", "_").replace(".", "_") + # Tag 优先级: 命令行 > agentengine.yaml version > config image.tag > latest image_tag = self.tag if not image_tag: - image_tag = config.get('version', '') # 使用项目版本作为 tag + image_tag = config.get("version", "") # 使用项目版本作为 tag if not image_tag: - image_tag = config.get('image', {}).get('tag', 'latest') - + image_tag = config.get("image", {}).get("tag", "latest") + # Registry 优先级: 命令行 > .env KCR_REGISTRY > agentengine.yaml > 默认企业版 KCR import os + image_registry = self.registry if not image_registry: - image_registry = os.getenv('KCR_REGISTRY', '') + image_registry = os.getenv("KCR_REGISTRY", "") if not image_registry: - image_registry = config.get('image', {}).get('registry', '') + image_registry = config.get("image", {}).get("registry", "") if not image_registry: # 默认使用企业版 KCR,智能选择内网/公网 - image_registry = self._get_smart_kcr_endpoint(os.getenv('KSYUN_REGION', 'cn-beijing-6')) + image_registry = self._get_smart_kcr_endpoint(os.getenv("KSYUN_REGION", "cn-beijing-6")) else: # 即使设置了 registry,如果是金山云公网地址,也尝试优化为内网 image_registry = self._optimize_kcr_endpoint(image_registry) - + full_image = f"{image_registry}/{image_name}:{image_tag}" - + click.echo(f"🏷️ 镜像名称: {full_image}") - + # 打包 (内置打包逻辑,不再依赖 DockerProvider) click.echo("\n📦 打包中...") try: package_info = self._package(result) click.echo("✅ 打包完成") except Exception as e: - return BuildResult( - success=False, - error_message=f"打包失败: {e}" - ) - + return BuildResult(success=False, error_message=f"打包失败: {e}") + # Docker 构建 if not ensure_docker_running(): - return BuildResult( - success=False, - error_message="Docker 未运行" - ) - + return BuildResult(success=False, error_message="Docker 未运行") + click.echo("\n🔨 构建 Docker 镜像 (目标平台: linux/amd64)...") try: - cmd = ['docker', 'build', '--platform', 'linux/amd64', '-t', full_image] + cmd = ["docker", "build", "--platform", "linux/amd64", "-t", full_image] if self.no_cache: - cmd.append('--no-cache') + cmd.append("--no-cache") cmd.append(package_info.build_dir) quiet_mode = os.getenv("AGENTENGINE_OUTPUT_MODE", "").strip().lower() == "json" @@ -628,16 +656,16 @@ def build(self) -> BuildResult: capture_output=quiet_mode, text=quiet_mode, ) - click.secho(f"\n✅ 镜像构建成功: {full_image}", fg='green') - + click.secho(f"\n✅ 镜像构建成功: {full_image}", fg="green") + return BuildResult( success=True, artifact_path=None, # Docker 镜像没有本地文件路径 metadata={ "image": full_image, "framework": result.type.value, - "build_dir": package_info.build_dir - } + "build_dir": package_info.build_dir, + }, ) except subprocess.CalledProcessError as e: error_message = f"镜像构建失败: {e}" @@ -647,138 +675,130 @@ def build(self) -> BuildResult: detail = stderr or stdout if detail: error_message = f"{error_message}: {detail}" - return BuildResult( - success=False, - error_message=error_message - ) - + return BuildResult(success=False, error_message=error_message) + def push(self, image_name: str) -> bool: """推送镜像到仓库""" # 检查镜像仓库认证 registry = self._extract_registry(image_name) - + # 尝试使用 .env 中的认证信息自动登录 if registry: if not self._auto_login_from_env(registry): # 自动登录失败,检查是否已有认证 if not self._check_registry_auth(registry): return False - - click.echo(f"\n📤 推送镜像...") + + click.echo("\n📤 推送镜像...") try: - result = subprocess.run( - ['docker', 'push', image_name], - capture_output=True, - text=True - ) + result = subprocess.run(["docker", "push", image_name], capture_output=True, text=True) if result.returncode != 0: # 检查是否是认证问题 - if 'denied' in result.stderr.lower() or 'unauthorized' in result.stderr.lower(): - self._print_auth_help(registry or 'docker.io') + if "denied" in result.stderr.lower() or "unauthorized" in result.stderr.lower(): + self._print_auth_help(registry or "docker.io") return False - click.secho(f"❌ 镜像推送失败: {result.stderr}", fg='red') + click.secho(f"❌ 镜像推送失败: {result.stderr}", fg="red") return False - - click.secho(f"✅ 镜像推送成功", fg='green') + + click.secho("✅ 镜像推送成功", fg="green") return True except subprocess.CalledProcessError as e: - click.secho(f"❌ 镜像推送失败: {e}", fg='red') + click.secho(f"❌ 镜像推送失败: {e}", fg="red") return False - + def _auto_login_from_env(self, registry: str) -> bool: """尝试使用 .env 中的凭证自动登录""" import os + from dotenv import load_dotenv - + # 加载 .env load_dotenv() - - # KCR_REGISTRY is a generic registry/namespace target used for KCR and third-party registries. - kcr_registry = os.getenv('KCR_REGISTRY', '') + + # KCR_REGISTRY is the registry/namespace target for KCR and third parties. + kcr_registry = os.getenv("KCR_REGISTRY", "") if not kcr_registry: kcr_registry = "hub.kce.ksyun.com/agentengine" - + kcr_username, kcr_password, registry_kind = resolve_registry_credentials(registry) - + # 检查是否匹配当前 registry (支持部分匹配) if registry not in kcr_registry and kcr_registry not in registry: return False - + if not kcr_username or not kcr_password: print_registry_credentials_help(registry, kind=registry_kind) return False - + click.echo(f"🔐 使用 .env 中的凭证登录 {registry}...") try: result = subprocess.run( - ['docker', 'login', registry, '-u', kcr_username, '--password-stdin'], + ["docker", "login", registry, "-u", kcr_username, "--password-stdin"], input=kcr_password, capture_output=True, text=True, - timeout=30 + timeout=30, ) if result.returncode == 0: - click.secho(f"✅ 登录成功", fg='green') + click.secho("✅ 登录成功", fg="green") return True else: - click.secho(f"⚠️ 自动登录失败: {result.stderr.strip()}", fg='yellow') + click.secho(f"⚠️ 自动登录失败: {result.stderr.strip()}", fg="yellow") return False except Exception as e: - click.secho(f"⚠️ 自动登录异常: {e}", fg='yellow') + click.secho(f"⚠️ 自动登录异常: {e}", fg="yellow") return False - + def get_registry_credentials(self, registry: str | None = None) -> dict: """获取镜像仓库凭证 (用于传给 Serverless) - + 返回扁平化结构: {"username": "...", "password": "..."} """ import os + from dotenv import load_dotenv - + load_dotenv() - + registry = registry or os.getenv("KCR_REGISTRY", "") username, password, _ = resolve_registry_credentials(registry) - + if username and password: - return { - 'username': username, - 'password': password - } + return {"username": username, "password": password} return {} - + def _extract_registry(self, image_name: str) -> Optional[str]: """从镜像名称提取仓库地址""" # 格式: registry/namespace/image:tag 或 namespace/image:tag (默认 docker.io) - parts = image_name.split('/') - if len(parts) >= 2 and ('.' in parts[0] or ':' in parts[0]): + parts = image_name.split("/") + if len(parts) >= 2 and ("." in parts[0] or ":" in parts[0]): return parts[0] return None # 使用默认 docker.io - + def _check_registry_auth(self, registry: str) -> bool: """检查是否已登录镜像仓库""" try: # 尝试获取 docker 配置 import json from pathlib import Path - - docker_config = Path.home() / '.docker' / 'config.json' + + docker_config = Path.home() / ".docker" / "config.json" if docker_config.exists(): with open(docker_config) as f: config = json.load(f) - auths = config.get('auths', {}) + auths = config.get("auths", {}) # 检查是否有该仓库的认证信息 - if registry in auths or f'https://{registry}' in auths: + if registry in auths or f"https://{registry}" in auths: return True - + # 没有找到认证信息,提示用户 - click.secho(f"\n⚠️ 未检测到 {registry} 的登录凭证", fg='yellow') + click.secho(f"\n⚠️ 未检测到 {registry} 的登录凭证", fg="yellow") self._print_auth_help(registry) return False except Exception: # 无法检查,继续尝试推送 return True - + def _print_auth_help(self, registry: str): """打印认证帮助信息""" print_registry_credentials_help(registry) diff --git a/ksadk/builders/framework_requirements.py b/ksadk/builders/framework_requirements.py index 9c94f1e2..2442c8d9 100644 --- a/ksadk/builders/framework_requirements.py +++ b/ksadk/builders/framework_requirements.py @@ -4,30 +4,46 @@ from typing import Iterable - FASTAPI_REQUIREMENT = "fastapi>=0.100.0,<1.0.0" ADK_REQUIREMENTS = ( - "google-adk>=1.34.0,<2.0.0", + # goal-00: 与 ksadk 自身 adk extra 对齐,允许 1.34.x 与 2.x + "google-adk>=1.34.0,<3.0.0", "litellm>=1.0.0", ) LANGCHAIN_ECOSYSTEM_REQUIREMENTS = ( - "langchain>=1.3.0,<2.0.0", - "langchain-openai>=1.2.0,<2.0.0", - "langchain-core>=1.4.0,<2.0.0", + "langchain>=1.3.14,<2.0.0", + "langchain-openai>=1.4.0,<2.0.0", + "langchain-core>=1.5.0,<2.0.0", "langgraph>=1.2.0,<1.3.0", ) -DEEPAGENTS_REQUIREMENTS = ( - "deepagents>=0.6.2,<1.0.0", -) +DEEPAGENTS_REQUIREMENTS = ("deepagents>=0.6.2,<1.0.0",) + +# codex runtime:openai-codex SDK(自带 codex CLI 二进制,见 PyPI cli-bin wheel) +CODEX_REQUIREMENTS = ("openai-codex==0.144.4",) + + +def code_requirements_for_framework(framework: str) -> list[str]: + """Dependencies safe to install into a portable Code artifact. + + Codex carries a platform-selected executable package. ManagedRuntime code + bundles must remain system independent, so Codex is deliberately excluded + from this dependency policy. + """ + normalized = (framework or "").strip().lower() + if normalized == "codex": + return [] + return requirements_for_framework(normalized) def requirements_for_framework(framework: str) -> list[str]: normalized = (framework or "").strip().lower() if normalized == "adk": return list(ADK_REQUIREMENTS) + if normalized == "codex": + return list(CODEX_REQUIREMENTS) if normalized in {"langchain", "langgraph", "deepagents"}: requirements = list(LANGCHAIN_ECOSYSTEM_REQUIREMENTS) if normalized == "deepagents": @@ -41,11 +57,13 @@ def minimal_requirements_for_framework(framework: str) -> list[str]: normalized = (framework or "").strip().lower() if normalized == "adk": return list(ADK_REQUIREMENTS) + if normalized == "codex": + return list(CODEX_REQUIREMENTS) if normalized in {"langchain", "langgraph", "deepagents"}: requirements = [ - "langchain>=1.3.0,<2.0.0", - "langchain-openai>=1.2.0,<2.0.0", - "langchain-core>=1.4.0,<2.0.0", + "langchain>=1.3.14,<2.0.0", + "langchain-openai>=1.4.0,<2.0.0", + "langchain-core>=1.5.0,<2.0.0", ] if normalized in {"langgraph", "deepagents"}: requirements.append("langgraph>=1.2.0,<1.3.0") diff --git a/ksadk/builders/ks3_uploader.py b/ksadk/builders/ks3_uploader.py index 97f2757c..25913fdf 100644 --- a/ksadk/builders/ks3_uploader.py +++ b/ksadk/builders/ks3_uploader.py @@ -5,14 +5,13 @@ import os import socket import time -from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path from typing import Optional -import click +import click from ks3.upload import UploadTask - from ksadk.common.constants import get_ks3_endpoints # KS3 Region 映射表 已移动到 ksadk.common.constants @@ -28,9 +27,9 @@ class KS3Uploader: UPLOAD_TIMEOUT_PER_MB_SECONDS = 4.0 UPLOAD_TIMEOUT_MAX_SECONDS = 3600 - def __init__(self, region: str = "cn-beijing-6", bucket: str = None): + def __init__(self, region: str = "cn-beijing-6", bucket: Optional[str] = None): """初始化 KS3 上传器 - + Args: region: KS3 区域 (默认 cn-beijing-6) bucket: bucket 名称 (可选) @@ -39,12 +38,12 @@ def __init__(self, region: str = "cn-beijing-6", bucket: str = None): - 如果环境变量也未设置,默认使用 agentengine-{region} """ self.region = region - + # 确定 bucket 名称 (优先级: 参数 > 环境变量 > 默认值) if bucket: self.bucket_name = bucket - elif os.getenv("KS3_BUCKET"): - self.bucket_name = os.getenv("KS3_BUCKET") + elif env_bucket := os.getenv("KS3_BUCKET"): + self.bucket_name = env_bucket else: # Bucket 名称格式: agentengine-{account_id}-{region} account_id = os.getenv("KSYUN_ACCOUNT_ID") @@ -68,7 +67,7 @@ def __init__(self, region: str = "cn-beijing-6", bucket: str = None): " 或设置 KS3_BUCKET 显式指定 bucket 名称" ) self.bucket_name = f"agentengine-{account_id}-{region}" - + self.custom_domain = None # 可选的自定义域名 def get_endpoint(self) -> str: @@ -100,8 +99,12 @@ def _upload_timeout_seconds(self, file_path: Path) -> int: pass size_mb = file_path.stat().st_size / (1024 * 1024) - calculated = self.UPLOAD_TIMEOUT_BASE_SECONDS + int(size_mb * self.UPLOAD_TIMEOUT_PER_MB_SECONDS) - return min(self.UPLOAD_TIMEOUT_MAX_SECONDS, max(self.UPLOAD_CONNECT_TIMEOUT_SECONDS, calculated)) + calculated = self.UPLOAD_TIMEOUT_BASE_SECONDS + int( + size_mb * self.UPLOAD_TIMEOUT_PER_MB_SECONDS + ) + return min( + self.UPLOAD_TIMEOUT_MAX_SECONDS, max(self.UPLOAD_CONNECT_TIMEOUT_SECONDS, calculated) + ) def _probe_endpoint_latency(self, host: str) -> Optional[float]: started_at = time.monotonic() @@ -137,12 +140,16 @@ def add_candidate(host: Optional[str], label: str) -> None: if mode == "internal": add_candidate(internal_endpoint, "内网") add_candidate(public_endpoint, "公网") - return candidates, f"强制优先内网 {candidates[0]['host']}" if candidates else "未找到可用 KS3 endpoint" + return candidates, ( + f"强制优先内网 {candidates[0]['host']}" if candidates else "未找到可用 KS3 endpoint" + ) if mode == "public": add_candidate(public_endpoint, "公网") add_candidate(internal_endpoint, "内网") - return candidates, f"强制优先公网 {candidates[0]['host']}" if candidates else "未找到可用 KS3 endpoint" + return candidates, ( + f"强制优先公网 {candidates[0]['host']}" if candidates else "未找到可用 KS3 endpoint" + ) add_candidate(internal_endpoint, "内网") add_candidate(public_endpoint, "公网") @@ -192,12 +199,24 @@ def _ensure_bucket(self, conn): bucket_exists = False else: if "AccessDenied" in error_str or "403" in error_str: - click.secho(f" ⚠️ 提示: Bucket '{self.bucket_name}' 名称冲突或无权限访问 (403)。", fg="yellow") - click.secho(" 注意: KS3 Bucket 名称在全网范围内是全局唯一的!", fg="yellow") + click.secho( + f" ⚠️ 提示: Bucket '{self.bucket_name}' 名称冲突或无权限访问 (403)。", + fg="yellow", + ) + click.secho( + " 注意: KS3 Bucket 名称在全网范围内是全局唯一的!", fg="yellow" + ) click.secho(" 该名称已被其他用户占用,您无法使用。", fg="yellow") click.secho(" 👉 解决方案:", fg="cyan") - click.secho(" 1. (推荐) 在 .env 中设置 KSYUN_ACCOUNT_ID 为您的账户 ID (自动生成唯一名称)。", fg="cyan") - click.secho(" 2. 或者,在 .env 中设置 KS3_BUCKET 为一个没人用过的唯一名称。", fg="cyan") + click.secho( + " 1. (推荐) 在 .env 中设置 KSYUN_ACCOUNT_ID 为您的账户 ID " + "(自动生成唯一名称)。", + fg="cyan", + ) + click.secho( + " 2. 或者,在 .env 中设置 KS3_BUCKET 为一个没人用过的唯一名称。", + fg="cyan", + ) raise if not bucket_exists: @@ -206,10 +225,20 @@ def _ensure_bucket(self, conn): click.secho(f" ✓ Bucket 创建成功: {self.bucket_name}", fg="green") except Exception as create_err: create_err_str = str(create_err) - if "Conflict" in create_err_str or "409" in create_err_str or "BucketAlreadyExists" in create_err_str: - click.secho(f" ⚠️ 提示: Bucket '{self.bucket_name}' 名称已被其他用户占用。", fg="yellow") + if ( + "Conflict" in create_err_str + or "409" in create_err_str + or "BucketAlreadyExists" in create_err_str + ): + click.secho( + f" ⚠️ 提示: Bucket '{self.bucket_name}' 名称已被其他用户占用。", + fg="yellow", + ) click.secho(" 注意: KS3 Bucket 名称是全局唯一的。", fg="yellow") - click.secho(" 👉 解决方法: 修改 .env 中的 KS3_BUCKET,换一个更复杂的名字再试。", fg="cyan") + click.secho( + " 👉 解决方法: 修改 .env 中的 KS3_BUCKET,换一个更复杂的名字再试。", + fg="cyan", + ) raise return bucket @@ -299,7 +328,9 @@ async def upload(self, file_path: Path, object_key: str) -> Optional[str]: return None click.echo(f" KS3 Endpoint 策略: {selection_summary}") - click.echo(f" 上传文件: {file_path.name} ({file_path.stat().st_size / (1024 * 1024):.2f} MB)") + click.echo( + f" 上传文件: {file_path.name} ({file_path.stat().st_size / (1024 * 1024):.2f} MB)" + ) click.echo(f" 上传超时: {self._upload_timeout_seconds(file_path)} 秒") # 临时禁用系统代理 (ClashX 等会导致 KS3 上传走代理而失败) @@ -369,17 +400,17 @@ def get_internal_url_by_key(self, object_key: str) -> str: async def upload_with_url(self, file_path: Path, presigned_url: str) -> bool: """使用预签名 URL 上传文件 (不依赖本地 AK/SK)""" import httpx - - click.echo(f" 上传中 (使用预签名 URL)...") - + + click.echo(" 上传中 (使用预签名 URL)...") + try: async with httpx.AsyncClient() as client: - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: response = await client.put( - presigned_url, + presigned_url, content=f.read(), headers={"Content-Type": "application/octet-stream"}, - timeout=300.0 + timeout=300.0, ) response.raise_for_status() click.secho(" ✓ 上传成功", fg="green") diff --git a/ksadk/builders/managed_runtime_builder.py b/ksadk/builders/managed_runtime_builder.py new file mode 100644 index 00000000..df927002 --- /dev/null +++ b/ksadk/builders/managed_runtime_builder.py @@ -0,0 +1,145 @@ +"""System-independent manifest bundles for platform-managed runtimes.""" + +from __future__ import annotations + +import hashlib +import json +import zipfile +from pathlib import Path +from typing import Any + +import yaml + +from ksadk.builders.base import BaseBuilder, BuildResult + +RUNTIME_MANIFEST_SCHEMA = "runtime-manifest/v1" +_MANIFEST_KEYS = ( + "name", + "version", + "framework", + "artifact_type", + "runtime", + "model", + "prompt", +) + + +class ManagedRuntimeBuilder(BaseBuilder): + """Build a deterministic YAML-only deployment artifact. + + Runtime dependencies and local credentials intentionally never enter this + artifact. The managed runtime image supplies executable dependencies while + AgentEngine injects deployment-time credentials. + """ + + def __init__( + self, + project_dir: Path, + config: dict[str, Any] | None = None, + *, + runtime_version: str | None = None, + ) -> None: + super().__init__(project_dir, config) + self.runtime_version = str(runtime_version or "").strip() + self.build_dir = self.project_dir / ".agentengine" / "managed_runtime" + + def build(self) -> BuildResult: + config = self._load_config() + error = self._validate_config(config) + if error: + return BuildResult(success=False, error_message=error) + + runtime = dict(config.get("runtime") or {}) + version = self.runtime_version or str(runtime.get("version") or "").strip() + if not version: + return BuildResult( + success=False, + error_message=( + "ManagedRuntime 构建需要已解析的 runtime.version;" + "请显式配置版本或连接 AgentEngine 获取服务端默认版本" + ), + ) + + 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_sha256 = hashlib.sha256(manifest_bytes).hexdigest() + lock = { + "schema_version": RUNTIME_MANIFEST_SCHEMA, + "runtime": runtime, + "manifest_sha256": manifest_sha256, + } + lock_bytes = ( + json.dumps(lock, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + + self.build_dir.mkdir(parents=True, exist_ok=True) + name = str(config.get("name") or self.project_dir.name).strip() or self.project_dir.name + project_version = str(config.get("version") or "1.0.0").strip() or "1.0.0" + artifact_path = self.build_dir / f"{name}-{project_version}-runtime.zip" + self._write_bundle( + artifact_path, + { + "agentengine.yaml": manifest_bytes, + "runtime-lock.json": lock_bytes, + }, + ) + return BuildResult( + success=True, + artifact_path=artifact_path, + artifact_size=artifact_path.stat().st_size, + metadata={ + "agent_name": name, + "framework": str(config.get("framework") or ""), + "artifact_type": "ManagedRuntime", + "runtime_name": runtime_name, + "runtime_version": version, + "manifest_sha256": manifest_sha256, + }, + ) + + @staticmethod + def _validate_config(config: dict[str, Any]) -> str | None: + framework = str(config.get("framework") or "").strip().lower() + artifact_type = str(config.get("artifact_type") or "").strip().lower() + runtime = config.get("runtime") + if framework != "codex": + return "当前 ManagedRuntime v1 仅支持 framework: codex" + if artifact_type != "managedruntime": + return "ManagedRuntime 项目必须配置 artifact_type: ManagedRuntime" + if not isinstance(runtime, dict): + return "ManagedRuntime 项目必须配置 runtime.name" + runtime_name = str(runtime.get("name") or "").strip().lower() + if runtime_name != "codex": + return "当前 ManagedRuntime v1 仅支持 runtime.name: codex" + return None + + @staticmethod + def _normalized_manifest( + config: dict[str, Any], + runtime: dict[str, str], + ) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for key in _MANIFEST_KEYS: + if key == "runtime": + normalized[key] = runtime + elif key == "artifact_type": + normalized[key] = "ManagedRuntime" + elif key in config: + normalized[key] = config[key] + return normalized + + @staticmethod + def _write_bundle(path: Path, files: dict[str, bytes]) -> None: + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name in sorted(files): + info = zipfile.ZipInfo(name) + info.date_time = (1980, 1, 1, 0, 0, 0) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, files[name]) diff --git a/ksadk/builders/mcp_builder.py b/ksadk/builders/mcp_builder.py index 2700f34a..62c2f5c8 100644 --- a/ksadk/builders/mcp_builder.py +++ b/ksadk/builders/mcp_builder.py @@ -10,66 +10,69 @@ import shutil import subprocess from pathlib import Path -from typing import List +from typing import List, Optional import click -from ksadk.builders.code_builder import CodeBuilder from ksadk.builders.base import BuildResult +from ksadk.builders.code_builder import CodeBuilder from ksadk.builders.container_builder import ContainerBuilder, ensure_docker_running from ksadk.builders.requirements_utils import merge_requirement_lists, parse_requirements_text -from ksadk.detection.mcp_detector import MCPDetector, MCPDetectionResult +from ksadk.detection.mcp_detector import MCPDetectionResult, MCPDetector class MCPCodeBuilder(CodeBuilder): """MCP 代码构建器""" - - def __init__(self, project_dir: Path, config: dict = None): + + def __init__(self, project_dir: Path, config: Optional[dict] = None): super().__init__(project_dir, config) self.build_dir = self.project_dir / ".agentengine" / "mcp_build" self.deps_dir = self.build_dir / "linux_deps" - self._mcp_detection: MCPDetectionResult = None - + self._mcp_detection: Optional[MCPDetectionResult] = None + def build(self) -> BuildResult: """执行 MCP 构建""" self._load_dotenv() config = self._load_config() - + # 检测 MCP 项目 detector = MCPDetector(str(self.project_dir)) detection_result = detector.detect() self._mcp_detection = detection_result - + if not detection_result.is_valid: - return BuildResult( - success=False, - error_message="未检测到 FastMCP 项目" - ) - + return BuildResult(success=False, error_message="未检测到 FastMCP 项目") + click.echo(f"📦 MCP Server: {click.style(detection_result.name, fg='green')}") if detection_result.tools: click.echo(f" 工具: {', '.join(detection_result.tools[:5])}") if len(detection_result.tools) > 5: click.echo(f" ... 及其他 {len(detection_result.tools) - 5} 个") - - mcp_name = config.get('name', self.project_dir.name).replace('-', '_').replace('.', '_') - + + mcp_name = config.get("name", self.project_dir.name).replace("-", "_").replace(".", "_") + # 创建构建目录 self.build_dir.mkdir(parents=True, exist_ok=True) zip_path = self.build_dir / f"{mcp_name}.zip" - + # 检查是否需要重新构建 no_cache = self.config.get("no_cache", False) if self.config else False - if zip_path.exists() and not no_cache and not self._need_rebuild(zip_path, detection_result): + if ( + zip_path.exists() + and not no_cache + and not self._need_rebuild(zip_path, detection_result) + ): incompatibles = self._scan_incompatible_binaries_in_zip(zip_path) if incompatibles: - click.secho("\n⚠️ 检测到缓存 MCP 构建包含非 Linux 兼容关键二进制,自动重建...", fg='yellow') + click.secho( + "\n⚠️ 检测到缓存 MCP 构建包含非 Linux 兼容关键二进制,自动重建...", fg="yellow" + ) for item in incompatibles[:5]: click.echo(f" - {item}") else: self._save_input_fingerprint(zip_path, detection_result) zip_size = zip_path.stat().st_size / (1024 * 1024) - click.secho(f"\n✅ 使用已有构建: {zip_path.name} ({zip_size:.2f} MB)", fg='green') + click.secho(f"\n✅ 使用已有构建: {zip_path.name} ({zip_size:.2f} MB)", fg="green") return BuildResult( success=True, artifact_path=zip_path, @@ -77,36 +80,34 @@ def build(self) -> BuildResult: metadata={ "mcp_name": mcp_name, "mcp_variable": detection_result.mcp_variable, - "tools": detection_result.tools - } + "tools": detection_result.tools, + }, ) - + # Step 1: 准备依赖 click.echo("\n📋 Step 1/3: 准备依赖清单...") requirements_path = self._prepare_mcp_requirements(detection_result) - + # Step 2: 安装依赖(在 Mac 上安装后自动替换为 Linux 二进制,和 agent code 模式一致) click.echo("\n📦 Step 2/3: 安装依赖...") if self.deps_dir.exists(): import shutil + shutil.rmtree(self.deps_dir) self.deps_dir.mkdir(parents=True) - + if not self._install_dependencies(requirements_path): - return BuildResult( - success=False, - error_message="依赖安装失败" - ) - + return BuildResult(success=False, error_message="依赖安装失败") + # Step 3: 打包 click.echo("\n📦 Step 3/3: 打包 zip...") self._package_mcp_zip(zip_path, detection_result) self._save_input_fingerprint(zip_path, detection_result) - + zip_size = zip_path.stat().st_size click.echo(f" zip 文件: {zip_path}") click.echo(f" 大小: {zip_size / (1024 * 1024):.2f} MB") - + return BuildResult( success=True, artifact_path=zip_path, @@ -115,33 +116,33 @@ def build(self) -> BuildResult: "mcp_name": mcp_name, "mcp_variable": detection_result.mcp_variable, "tools": detection_result.tools, - "deps_dir": str(self.deps_dir) - } + "deps_dir": str(self.deps_dir), + }, ) - + def _prepare_mcp_requirements(self, detection_result: MCPDetectionResult) -> Path: """准备 MCP 依赖""" base_deps = self._get_mcp_base_requirements() final_deps = list(base_deps) - + # 合并用户依赖 user_requirements = self.project_dir / "requirements.txt" if user_requirements.exists(): - click.echo(f" 发现 requirements.txt,正在合并...") + click.echo(" 发现 requirements.txt,正在合并...") user_content = user_requirements.read_text() user_deps = parse_requirements_text(user_content) final_deps = merge_requirement_lists(final_deps, user_deps) else: - click.echo(f" 使用默认 MCP 依赖") - + click.echo(" 使用默认 MCP 依赖") + # 写入构建目录 requirements_path = self.build_dir / "requirements.txt" requirements_path.write_text("\n".join(final_deps)) - + click.echo(f" 共 {len(final_deps)} 个依赖包") - + return requirements_path - + def _get_mcp_base_requirements(self) -> List[str]: """MCP 基础依赖""" return [ @@ -154,40 +155,38 @@ def _get_mcp_base_requirements(self) -> List[str]: "python-dotenv>=1.0.0", "pydantic>=2.0.0", ] - + def _package_mcp_zip(self, zip_path: Path, detection_result: MCPDetectionResult) -> None: """打包 MCP zip""" import zipfile - + file_count = 0 - - with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: # 添加项目文件 for file_path in self._iter_project_files(): arcname = file_path.relative_to(self.project_dir).as_posix() zf.write(file_path, arcname) file_count += 1 - + # 添加依赖(已替换为 Linux 二进制) deps_count = 0 - for file_path in self.deps_dir.rglob('*'): + for file_path in self.deps_dir.rglob("*"): if file_path.is_file(): arcname = str(file_path.relative_to(self.deps_dir)) zf.write(file_path, arcname) deps_count += 1 - + # 生成 MCP entrypoint entrypoint_content = self._generate_mcp_entrypoint(detection_result) zf.writestr("entrypoint.py", entrypoint_content) - + click.echo(f" ✓ 打包完成: {file_count} 个项目文件 + {deps_count} 个依赖文件") - + def _generate_mcp_entrypoint(self, detection_result: MCPDetectionResult) -> str: """生成 MCP 入口脚本""" # 确定包名 entry_point = detection_result.entry_point - package_path = Path(detection_result.package_path) - # 计算模块路径 if "/" in entry_point: # 如果入口在子目录,如 my_mcp/server.py @@ -195,9 +194,9 @@ def _generate_mcp_entrypoint(self, detection_result: MCPDetectionResult) -> str: else: # 根目录 module_path = entry_point.replace(".py", "") - + mcp_variable = detection_result.mcp_variable - + return f'''""" AgentEngine MCP 入口 @@ -244,15 +243,20 @@ def _generate_mcp_entrypoint(self, detection_result: MCPDetectionResult) -> str: from {module_path} import {mcp_variable} logger.info(f"MCP 实例: {mcp_variable}") -logger.info(f"工具数量: {{len(getattr({mcp_variable}, '_tool_manager', {{}}).list_tools() if hasattr({mcp_variable}, '_tool_manager') else 'N/A')}}") +tool_count = ( + len(getattr({mcp_variable}, "_tool_manager", {{}}).list_tools()) + if hasattr({mcp_variable}, "_tool_manager") + else "N/A" +) +logger.info(f"工具数量: {{tool_count}}") if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) host = os.environ.get("HOST", "0.0.0.0") - + logger.info(f"启动 HTTP Server: {{host}}:{{port}}") logger.info("MCP endpoint: /mcp") - + {mcp_variable}.run( transport="http", host=host, @@ -264,8 +268,14 @@ def _generate_mcp_entrypoint(self, detection_result: MCPDetectionResult) -> str: class MCPContainerBuilder(ContainerBuilder): """FastMCP Docker 镜像构建器。""" - def __init__(self, project_dir: Path, config: dict = None, - tag: str = None, registry: str = None, no_cache: bool = False): + def __init__( + self, + project_dir: Path, + config: Optional[dict] = None, + tag: Optional[str] = None, + registry: Optional[str] = None, + no_cache: bool = False, + ): super().__init__(project_dir, config=config, tag=tag, registry=registry, no_cache=no_cache) def build(self) -> BuildResult: @@ -277,28 +287,25 @@ def build(self) -> BuildResult: result = detector.detect() if not result.is_valid: - return BuildResult( - success=False, - error_message="未检测到 FastMCP 项目" - ) + return BuildResult(success=False, error_message="未检测到 FastMCP 项目") click.echo(f"📦 MCP Server: {click.style(result.name, fg='green')}") - image_name = config.get('name', self.project_dir.name).replace('-', '_').replace('.', '_') + image_name = config.get("name", self.project_dir.name).replace("-", "_").replace(".", "_") image_tag = self.tag if not image_tag: - image_tag = config.get('version', '') + image_tag = config.get("version", "") if not image_tag: - image_tag = config.get('image', {}).get('tag', 'latest') + image_tag = config.get("image", {}).get("tag", "latest") image_registry = self.registry if not image_registry: - image_registry = os.getenv('KCR_REGISTRY', '') + image_registry = os.getenv("KCR_REGISTRY", "") if not image_registry: - image_registry = config.get('image', {}).get('registry', '') + image_registry = config.get("image", {}).get("registry", "") if not image_registry: - image_registry = self._get_smart_kcr_endpoint(os.getenv('KSYUN_REGION', 'cn-beijing-6')) + image_registry = self._get_smart_kcr_endpoint(os.getenv("KSYUN_REGION", "cn-beijing-6")) else: image_registry = self._optimize_kcr_endpoint(image_registry) @@ -310,22 +317,16 @@ def build(self) -> BuildResult: package_info = self._package_mcp_project(result) click.echo("✅ 打包完成") except Exception as e: - return BuildResult( - success=False, - error_message=f"打包失败: {e}" - ) + return BuildResult(success=False, error_message=f"打包失败: {e}") if not ensure_docker_running(): - return BuildResult( - success=False, - error_message="Docker 未运行" - ) + return BuildResult(success=False, error_message="Docker 未运行") click.echo("\n🔨 构建 Docker 镜像 (目标平台: linux/amd64)...") try: - cmd = ['docker', 'build', '--platform', 'linux/amd64', '-t', full_image] + cmd = ["docker", "build", "--platform", "linux/amd64", "-t", full_image] if self.no_cache: - cmd.append('--no-cache') + cmd.append("--no-cache") cmd.append(package_info.build_dir) quiet_mode = os.getenv("AGENTENGINE_OUTPUT_MODE", "").strip().lower() == "json" @@ -335,7 +336,7 @@ def build(self) -> BuildResult: capture_output=quiet_mode, text=quiet_mode, ) - click.secho(f"\n✅ 镜像构建成功: {full_image}", fg='green') + click.secho(f"\n✅ 镜像构建成功: {full_image}", fg="green") return BuildResult( success=True, @@ -346,7 +347,7 @@ def build(self) -> BuildResult: "build_dir": package_info.build_dir, "mcp_variable": result.mcp_variable, "tools": result.tools, - } + }, ) except subprocess.CalledProcessError as e: error_message = f"镜像构建失败: {e}" @@ -356,10 +357,7 @@ def build(self) -> BuildResult: detail = stderr or stdout if detail: error_message = f"{error_message}: {detail}" - return BuildResult( - success=False, - error_message=error_message - ) + return BuildResult(success=False, error_message=error_message) def _package_mcp_project(self, detection_result: MCPDetectionResult): """复制项目并生成 FastMCP 容器运行所需文件。""" @@ -372,16 +370,17 @@ def _package_mcp_project(self, detection_result: MCPDetectionResult): for item in project_path.iterdir(): if CodeBuilder._is_real_dotenv_file(item.name): continue - if ( - (item.name.startswith('.') and item.name != '.env.example') - or item.name in ('__pycache__', '.git', 'node_modules') + if (item.name.startswith(".") and item.name != ".env.example") or item.name in ( + "__pycache__", + ".git", + "node_modules", ): continue dest = output_dir / item.name if item.is_dir(): if dest.exists(): shutil.rmtree(dest) - shutil.copytree(item, dest, ignore=shutil.ignore_patterns('__pycache__', '*.pyc')) + shutil.copytree(item, dest, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) else: shutil.copy2(item, dest) @@ -406,7 +405,7 @@ def _package_mcp_project(self, detection_result: MCPDetectionResult): "entrypoint": str(entrypoint_path), "mcp_variable": detection_result.mcp_variable, "tools": detection_result.tools, - } + }, ) def _generate_mcp_requirements(self, project_path: Path) -> str: diff --git a/ksadk/builders/requirements_utils.py b/ksadk/builders/requirements_utils.py index 734915c7..fc9afede 100644 --- a/ksadk/builders/requirements_utils.py +++ b/ksadk/builders/requirements_utils.py @@ -5,7 +5,6 @@ from packaging.requirements import InvalidRequirement, Requirement - _NORMALIZED_NAME_RE = re.compile(r"[-_.]+") @@ -47,10 +46,7 @@ def exclude_requirement_names( *, excluded_names: Iterable[str], ) -> List[str]: - excluded = { - _NORMALIZED_NAME_RE.sub("-", name).lower() - for name in excluded_names - } + excluded = {_NORMALIZED_NAME_RE.sub("-", name).lower() for name in excluded_names} filtered: List[str] = [] for raw_requirement in requirements: diff --git a/ksadk/cli/__init__.py b/ksadk/cli/__init__.py index 6cafb5c6..7f8f90bb 100644 --- a/ksadk/cli/__init__.py +++ b/ksadk/cli/__init__.py @@ -81,7 +81,7 @@ def _gradient_line(text: str, colors: list) -> str: "config", } SHORT_HELP_MAP = { - "a2a": "暴露 A2A 服务与 Agent Card", + "a2a": "A2A 协议服务与本地试调", "agent": "Agent 资源管理", "build": "构建部署制品", "completion": "Shell 补全管理", @@ -180,6 +180,10 @@ def format_help(self, ctx, formatter): _write_colored_help_row(formatter, "agentengine deploy", "部署到云端") _write_colored_help_row(formatter, "agentengine launch", "一键构建+部署") _write_colored_help_row(formatter, "agentengine agent", "Agent 资源管理") + _write_colored_help_row(formatter, "agentengine version", "Agent 版本管理") + _write_colored_help_row(formatter, "agentengine mcp", "MCP 资源管理") + _write_colored_help_row(formatter, "agentengine a2a", "A2A 协议服务与本地试调") + _write_colored_help_row(formatter, "agentengine files", "管理云端 workspace 文件") _write_colored_help_row(formatter, "agentengine dashboard", "打开云端 Agent Dashboard") _write_colored_help_row(formatter, "agentengine hermes", "Hermes Agent 资源管理") _write_colored_help_row(formatter, "agentengine openclaw", "OpenClaw 资源管理") @@ -187,13 +191,11 @@ def format_help(self, ctx, formatter): # 配置与工具 formatter.write(click.style(" 🧰 配置:\n\n", fg="yellow", bold=True)) _write_colored_help_row(formatter, "agentengine config", "项目配置向导与模型配置") + _write_colored_help_row(formatter, "agentengine completion", "Shell 补全管理") # 自定义 Options 格式化 self.format_options_colored(ctx, formatter) - # 自定义 Commands 格式化 - self.format_commands_colored(ctx, formatter) - def format_options_colored(self, ctx, formatter): """自定义选项格式化""" formatter.write(click.style(" ⚙️ 选项:\n\n", fg="yellow", bold=True)) @@ -212,7 +214,8 @@ def format_help_plain(self, ctx, formatter): formatter.write_paragraph() formatter.write_text("AgentEngine CLI") formatter.write_text( - "支持 Hermes / OpenClaw / DeepAgents / LangGraph / LangChain / Google ADK 的本地运行与云端部署。" + "支持 Hermes / OpenClaw / DeepAgents / LangGraph / LangChain / Google ADK " + "的本地运行与云端部署。" ) formatter.write_paragraph() @@ -236,61 +239,6 @@ def format_help_plain(self, ctx, formatter): formatter.write_paragraph() formatter.write_text("使用 `agentengine --help` 查看子命令帮助。") - def format_commands_colored(self, ctx, formatter): - """自定义命令列表格式,更简洁美观""" - icon_map = { - "a2a": "↔️", - "agent": "🤖", - "build": "🔨", - "completion": "⌨️", - "dashboard": "🖥️", - "deploy": "🚀", - "files": "📄", - "hermes": "⌁", - "init": "📁", - "launch": "✨", - "mcp": "🔌", - "openclaw": "🦞", - "run": "▶️", - "version": "🏷️", - "web": "🌐", - } - - commands = [] - for subcommand in self.list_commands(ctx): - cmd = self.get_command(ctx, subcommand) - if cmd is None: - continue - if getattr(cmd, "hidden", False): - continue - if subcommand not in ROOT_HELP_COMMANDS: - continue - commands.append((subcommand, cmd)) - - if not commands: - return - - formatter.write(click.style("\n 📋 可用命令:\n\n", fg="magenta", bold=True)) - - # Emoji、变体选择符和 CJK 字符的 Python len 与终端 cell 宽度不同。 - # 这里按 cell 宽度补齐,保证命令列和说明列在真实终端里同列。 - icon_width = max(_terminal_cell_width(icon) for icon in icon_map.values()) - max_cmd_width = max(max(_terminal_cell_width(name) for name, _ in commands), 16) - - for subcommand, cmd in commands: - # 使用预定义的简短描述 - help_text = SHORT_HELP_MAP.get(subcommand, "") - if not help_text and cmd.help: - # 只取 docstring 第一行 - help_text = cmd.help.split("\n")[0].strip() - - # 格式化输出 - icon = icon_map.get(subcommand, "•") - icon_cell = _pad_to_terminal_cells(icon, icon_width) - name_cell = _pad_to_terminal_cells(subcommand, max_cmd_width) - formatter.write(click.style(f" {icon_cell} ", fg="cyan")) - formatter.write(click.style(f"{name_cell} ", fg="cyan")) - formatter.write(click.style(f"{help_text}\n\n", fg="white")) @click.group(cls=ColoredHelpGroup, context_settings=CONTEXT_SETTINGS) @click.version_option(version=VERSION, prog_name="AgentEngine") @@ -311,138 +259,106 @@ def _add_command_once(group: click.Group, command, *, name: str | None = None): group.add_command(command, name=name) -def _register_commands(): - from ksadk.cli.cmd_create import create - from ksadk.cli.cmd_deploy import deploy - from ksadk.cli.cmd_run import run - from ksadk.cli.cmd_web import web +def _unavailable_command_stub(cmd_name: str, module_path: str, exc: Exception) -> click.Group: + """命令加载失败时的**显式降级** stub:``-h`` 可见失败原因,调用报显式错误。 - # 注册现有命令 - _add_command_once(cli, run) - _add_command_once(cli, deploy) - _add_command_once(cli, web) + 替代 ``except ImportError: pass`` 静默吞(goal-16 Windows bug:``agentengine a2a -h`` + 曾因 cmd_a2a import 失败被吞而报 "No such command 'a2a'")。降级后 ``-h`` 仍退出 0 + 并显示失败原因,调用子命令报显式 ClickException——失败显式可见,不再静默。 + """ - # init 作为主命令 (PRD 规范) - _add_command_once(cli, create, name="init") + @click.group( + cmd_name, + context_settings=dict(help_option_names=["-h", "--help"]), + help=f"(加载失败){cmd_name} 命令不可用:{exc}", + invoke_without_command=True, + no_args_is_help=False, + ) + def _stub(): + raise click.ClickException(f"命令 {cmd_name!r} 不可用:加载 {module_path} 失败({exc})") - # 注册新命令 (如果存在) - try: - from ksadk.cli.cmd_a2a import a2a - - _add_command_once(cli, a2a) - except ImportError: - pass + return _stub - try: - from ksadk.cli.cmd_files import files - _add_command_once(cli, files) - except ImportError: - pass - - try: - from ksadk.cli.cmd_config import config - - _add_command_once(cli, config) - except ImportError: - pass - - try: - from ksadk.cli.cmd_model import model - - _add_command_once(cli, model) - except ImportError: - pass - - try: - from ksadk.cli.cmd_build import build - - _add_command_once(cli, build) - except ImportError: - pass - - try: - from ksadk.cli.cmd_launch import launch - - _add_command_once(cli, launch) - except ImportError: - pass - - try: - from ksadk.cli.cmd_agent import agent - - _add_command_once(cli, agent) - except ImportError: - pass +def _warn_unavailable_optional_command( + cmd_names: tuple[str, ...], module_path: str, exc: Exception +) -> None: + """Print actionable startup guidance while retaining the explicit command stub.""" + command_list = ", ".join(repr(name) for name in cmd_names) + message = ( + f"WARNING: optional command {command_list} is unavailable because {module_path} " + f"could not load: {exc}. The remaining ksadk commands are still available." + ) + if "a2a" in cmd_names: + message += ( + " KsADK 0.8 requires a2a-sdk==1.1.0; activate the intended virtual environment and " + 'run `python -m pip install --upgrade "ksadk[a2a]"`. A2A 0.3 projects must use a ' + "separate environment." + ) + click.echo(message, err=True) - try: - from ksadk.cli.cmd_status import status - _add_command_once(cli, status) - except ImportError: - pass +def _register_optional_command(cli: click.Group, module_path: str, *cmd_names: str) -> None: + """注册可选命令组;导入或依赖 API 不兼容时注册显式降级 stub(不静默吞)。""" + import importlib try: - from ksadk.cli.cmd_invoke import invoke + module = importlib.import_module(module_path) + # Optional command imports can fail before an ImportError is raised when an installed + # optional SDK exposes an incompatible API. Keep the top-level CLI usable and make the + # unavailable command report the original reason instead of masking it as a CLI crash. + except (ImportError, AttributeError) as exc: + _warn_unavailable_optional_command(cmd_names, module_path, exc) + for cmd_name in cmd_names: + _add_command_once(cli, _unavailable_command_stub(cmd_name, module_path, exc)) + return + for cmd_name in cmd_names: + _add_command_once(cli, getattr(module, cmd_name)) - _add_command_once(cli, invoke) - except ImportError: - pass - try: - from ksadk.cli.cmd_dashboard import dashboard +def _register_commands(): + from ksadk.cli.cmd_create import create + from ksadk.cli.cmd_deploy import deploy + from ksadk.cli.cmd_run import run + from ksadk.cli.cmd_web import web - _add_command_once(cli, dashboard) - except ImportError: - pass + # 注册现有命令 + _add_command_once(cli, run) + _add_command_once(cli, deploy) + _add_command_once(cli, web) - try: - from ksadk.cli.cmd_destroy import delete, destroy + # init 作为主命令 (PRD 规范) + _add_command_once(cli, create, name="init") - _add_command_once(cli, delete) - _add_command_once(cli, destroy) - except ImportError: - pass + # 注册可选命令组;加载失败时显式降级(不静默吞,见 _register_optional_command)。 + _register_optional_command(cli, "ksadk.cli.cmd_a2a", "a2a") + _register_optional_command(cli, "ksadk.cli.cmd_files", "files") + _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_launch", "launch") + _register_optional_command(cli, "ksadk.cli.cmd_agent", "agent") + _register_optional_command(cli, "ksadk.cli.cmd_status", "status") + _register_optional_command(cli, "ksadk.cli.cmd_invoke", "invoke") + _register_optional_command(cli, "ksadk.cli.cmd_dashboard", "dashboard") + _register_optional_command(cli, "ksadk.cli.cmd_replay", "replay") + _register_optional_command(cli, "ksadk.cli.cmd_destroy", "delete", "destroy") # MCP 命令组 - try: - from ksadk.cli.cmd_mcp import mcp - - _add_command_once(cli, mcp) - except ImportError: - pass + _register_optional_command(cli, "ksadk.cli.cmd_mcp", "mcp") # Completion 命令组 - try: - from ksadk.cli.cmd_completion import completion - - _add_command_once(cli, completion) - except ImportError: - pass + _register_optional_command(cli, "ksadk.cli.cmd_completion", "completion") # Version 命令组 - try: - from ksadk.cli.cmd_version import version - - _add_command_once(cli, version) - except ImportError: - pass + _register_optional_command(cli, "ksadk.cli.cmd_version", "version") # OpenClaw 命令组 - try: - from ksadk.cli.cmd_openclaw import openclaw - - _add_command_once(cli, openclaw) - except ImportError: - pass + _register_optional_command(cli, "ksadk.cli.cmd_openclaw", "openclaw") # Hermes 命令组 - try: - from ksadk.cli.cmd_hermes import hermes + _register_optional_command(cli, "ksadk.cli.cmd_hermes", "hermes") - _add_command_once(cli, hermes) - except ImportError: - pass def main(): # 全局加载 .env 文件 @@ -473,24 +389,25 @@ def main(): if not loaded: # 回退到系统默认编码 (Windows 上通常是 GBK/cp936) import locale + fallback_encoding = locale.getpreferredencoding(False) try: load_dotenv(dotenv_path, override=False, encoding=fallback_encoding) click.echo( click.style( f"⚠️ 警告: .env 文件使用 {fallback_encoding} 编码,建议转换为 UTF-8\n", - fg="yellow" + fg="yellow", ), - err=True + err=True, ) except UnicodeDecodeError: click.echo( click.style( f"❌ 错误: .env 文件编码无法识别,请确保使用 UTF-8 编码保存\n" f" 文件路径: {dotenv_path}\n", - fg="red" + fg="red", ), - err=True + err=True, ) except ImportError: pass @@ -498,6 +415,7 @@ def main(): # 全局配置回退: .env 未设置的变量从 ~/.agentengine/settings.json 补充 try: from ksadk.configs.global_config import get_env_from_global_config + global_env = get_env_from_global_config() injected_keys = [] for key, value in global_env.items(): diff --git a/ksadk/cli/cmd_a2a.py b/ksadk/cli/cmd_a2a.py index 1dfaedaf..4b3c879c 100644 --- a/ksadk/cli/cmd_a2a.py +++ b/ksadk/cli/cmd_a2a.py @@ -1,59 +1,110 @@ +# -*- coding: utf-8 -*- +"""ksadk a2a 子命令 — A2A 协议服务与本地试调 (goal-16,a2a-center-productization-2026-07 清洁重做)。 + +基于 goal-05 清洁重写的 A2A API(``A2AProtocolServer`` / ``build_agent_card`` / +``add_a2a_protocol_routes`` / ``A2ASpaceClient``),不使用旧 0.3.x demo API +(``AgentCardBuilder`` / ``KsA2AServer``,已删)——那是 Windows ``No such command 'a2a'`` +的根因(import 失败被 ``except ImportError: pass`` 静默吞)。 + +子命令:serve / card / register / discover / call / status / cancel。 +""" + from __future__ import annotations +import asyncio import json from pathlib import Path from typing import Sequence import click import uvicorn +from a2a.types import TaskState +from google.protobuf.json_format import MessageToJson import ksadk.configs as configs -from ksadk.a2a import AgentCardBuilder, KsA2AServer +from ksadk.a2a import ( + A2AConfig, + A2APlatformTask, + A2ASpaceClient, + add_a2a_protocol_routes, + build_agent_card, +) 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 + +_HELP = dict(help_option_names=["-h", "--help"]) +_DEFAULT_TASK_STORE_DSN = "sqlite+aiosqlite:///.agentengine/a2a_tasks.db" -@click.group("a2a", context_settings=CONTEXT_SETTINGS, help="A2A 协议服务与 Agent Card") +def _resolve_task_store_dsn(task_store_dsn: str, agent_path: Path) -> str: + """相对路径的 sqlite DSN 锚定到 agent_path(而非 cwd),并确保父目录存在。 + + 绝对 DSN(postgresql:// 等)原样返回。 + """ + prefix = "sqlite+aiosqlite:///" + if not task_store_dsn.startswith(prefix): + return task_store_dsn + raw_path = task_store_dsn[len(prefix) :] + if raw_path.startswith("/") or not raw_path: + return task_store_dsn + resolved = (agent_path / raw_path).resolve() + resolved.parent.mkdir(parents=True, exist_ok=True) + return f"{prefix}{resolved}" + + +@click.group("a2a", context_settings=CONTEXT_SETTINGS, help="A2A 协议服务与本地试调") def a2a(): - """A2A protocol helpers.""" + """A2A 协议命令组(serve / card / register / discover / call / status / cancel)。""" + +# --------------------------------------------------------------------------- +# serve / card(本地 A2A server 与 AgentCard) +# --------------------------------------------------------------------------- -@a2a.command("serve", context_settings=dict(help_option_names=["-h", "--help"])) + +@a2a.command("serve", context_settings=_HELP) @click.argument( - "agent_dir", - default=".", - type=click.Path(exists=True, file_okay=False, path_type=Path), + "agent_path", type=click.Path(exists=True, file_okay=False, path_type=Path), default="." ) -@click.option("--host", default="0.0.0.0", show_default=True, help="服务监听地址") +@click.option("--host", default="127.0.0.1", show_default=True, help="服务监听地址") @click.option("--port", default=8081, show_default=True, type=int, help="服务端口") -@click.option("--url", default=None, help="Agent Card 对外宣告地址") +@click.option("--url", default=None, help="Agent Card 对外宣告地址(默认 http://host:port)") @click.option("--name", default=None, help="覆盖 Agent 名称") @click.option("--description", default="", help="覆盖 Agent 描述") -@click.option("--skill", "skills", multiple=True, help="可重复传入,追加 Agent Card 技能") +@click.option("--skill", "skills", multiple=True, help="可重复传入,追加 Agent Card 技能") +@click.option( + "--task-store-dsn", + default=_DEFAULT_TASK_STORE_DSN, + show_default=True, + help="durable task store DSN(生产用 postgresql+asyncpg://)", +) +@click.option( + "--include-reasoning/--no-include-reasoning", + default=True, + show_default=True, + help="将可展示的 reasoning 作为 adk_thought artifact 流式输出", +) @click.option("--no-trace", is_flag=True, help="禁用 Tracing") def serve( - agent_dir: Path, + agent_path: Path, host: str, port: int, url: str | None, name: str | None, description: str, skills: Sequence[str], + task_store_dsn: str, + include_reasoning: bool, no_trace: bool, ): - """启动 A2A 协议服务。""" - agent_path = agent_dir.resolve() - command_args = [ - "a2a", - "serve", - str(agent_path), - "--host", - host, - "--port", - str(port), - ] + """把本地 agent 暴露为 A2A 协议服务(JSONRPC + REST + AgentCard)。""" + agent_path = agent_path.resolve() + command_args = ["a2a", "serve", str(agent_path), "--host", host, "--port", str(port)] if url: command_args.extend(["--url", url]) if name: @@ -62,28 +113,44 @@ def serve( command_args.extend(["--description", description]) for skill in skills: command_args.extend(["--skill", skill]) + if task_store_dsn != _DEFAULT_TASK_STORE_DSN: + command_args.extend(["--task-store-dsn", task_store_dsn]) + if not include_reasoning: + command_args.append("--no-include-reasoning") if no_trace: 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) - app_name = name or detection_result.name - public_url = url or f"http://127.0.0.1:{port}" - server = KsA2AServer( - runner=runner, - app_name=app_name, - url=public_url, + from fastapi import FastAPI + + app = FastAPI(title=f"ksadk A2A: {detection_result.name}") + config = A2AConfig( + enabled=True, + base_url=url or f"http://{host}:{port}", + agent_name=name or detection_result.name, description=description, - skills=skills, + skills=list(skills), + task_store_dsn=task_store_dsn, + 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), ) - uvicorn.run(server.build(), host=host, port=port) + click.echo(f"A2A agent card: {(url or f'http://{host}:{port}')}/.well-known/agent-card.json") + uvicorn.run(app, host=host, port=port) -@a2a.command("card", context_settings=dict(help_option_names=["-h", "--help"])) +@a2a.command("card", context_settings=_HELP) @click.argument( - "agent_dir", - default=".", - type=click.Path(exists=True, file_okay=False, path_type=Path), + "agent_path", type=click.Path(exists=True, file_okay=False, path_type=Path), default="." ) @click.option( "--url", @@ -93,28 +160,152 @@ def serve( ) @click.option("--name", default=None, help="覆盖 Agent 名称") @click.option("--description", default="", help="覆盖 Agent 描述") -@click.option("--skill", "skills", multiple=True, help="可重复传入,追加 Agent Card 技能") -def card( - agent_dir: Path, - url: str, - name: str | None, - description: str, - skills: Sequence[str], -): - """输出 Agent Card JSON。""" - agent_path = agent_dir.resolve() - detection_result = _detect_project(agent_path) - payload = _build_agent_card_payload( - app_name=name or detection_result.name, - url=url, +@click.option("--skill", "skills", multiple=True, help="可重复传入,追加 Agent Card 技能") +def card(agent_path: Path, url: str, name: str | None, description: str, skills: Sequence[str]): + """打印该 agent 的 wire 1.0 AgentCard(supportedInterfaces)。""" + # ``card`` is intentionally machine-readable: users commonly redirect it to + # ``agent-card.json``. Detection only needs the manifest, so avoid the + # environment bootstrap here; it may print helpful model-default banners. + detection_result = _detect_project(agent_path, configure_environment=False) + card_obj = build_agent_card( + name=name or detection_result.name, + base_url=url, + description=description, + skills=list(skills), + ) + click.echo(MessageToJson(card_obj, indent=2)) + + +@a2a.command("register", context_settings=_HELP) +@click.argument( + "agent_path", type=click.Path(exists=True, file_okay=False, path_type=Path), default="." +) +@click.option("--url", required=True, help="Agent Card 对外宣告地址") +@click.option("--name", default=None, help="覆盖 Agent 名称") +@click.option("--description", default="", help="覆盖 Agent 描述") +@click.option("--skill", "skills", multiple=True, help="可重复传入,追加 Agent Card 技能") +def register(agent_path: Path, url: str, name: str | None, description: str, skills: Sequence[str]): + """构造并打印用于 Space 注册的 AgentCard(本地试调;server 侧 KOP 注册见产品化文档)。""" + detection_result = _detect_project(agent_path, configure_environment=False) + card_obj = build_agent_card( + name=name or detection_result.name, + base_url=url, description=description, - skills=skills, + skills=list(skills), ) - click.echo(json.dumps(payload, ensure_ascii=False)) + click.echo( + "# 本地 AgentCard 预览;平台注册使用 GetA2AAgentCard + CreateA2AAgent," + "hosted 注册不接收用户手写 Card:" + ) + click.echo(MessageToJson(card_obj, indent=2)) + + +# --------------------------------------------------------------------------- +# discover / call / status / cancel(Space 内动态发现与调用,经 A2ASpaceClient) +# --------------------------------------------------------------------------- + + +def _space_client(space_id: str | None = None) -> A2ASpaceClient: + """从环境构造 A2ASpaceClient;Space 未配置时显式报错(不静默)。""" + try: + return A2ASpaceClient.from_env(space_id=space_id) + except ValueError as exc: + raise click.ClickException( + f"A2A Space 未配置:{exc}。" + "请传 --space-id,或由 AgentEngine 部署注入 KSADK_A2A_SPACE_IDS;同时需要" + "KSADK_A2A_CONTROL_PLANE_URL 和 audience workload token。" + ) from exc + + +@a2a.command("discover", context_settings=_HELP) +@click.option("--space-id", default=None, help="本次发现使用的 A2A Space ID") +@click.option("--prompt", default=None, help="发现关键词(受控匹配)") +@click.option("--skill", default=None, help="按技能过滤") +def discover(space_id: str | None, prompt: str | None, skill: str | None): + """发现 Space 中 hosted/external Agent 的 latest AgentCard。""" + + async def _run() -> None: + client = _space_client(space_id) + agents = await client.discover(prompt=prompt, skill=skill) + for agent in agents: + click.echo( + json.dumps( + { + "agent_id": agent.agent_id, + "version_id": agent.version_id, + "source": agent.source, + "name": getattr(agent.agent_card, "name", ""), + "route_kind": agent.route_kind, + "callable": agent.callable, + "blocked_reason": agent.blocked_reason, + }, + ensure_ascii=False, + ) + ) + + asyncio.run(_run()) + + +@a2a.command("call", context_settings=_HELP) +@click.option("--space-id", default=None, help="本次调用使用的 A2A Space ID") +@click.argument("agent_id") +@click.argument("message") +def call(space_id: str | None, agent_id: str, message: str): + """向 Space 中某 Agent 发送消息,返回首个 Task。""" + + async def _run() -> None: + client = _space_client(space_id) + task = await client.send_message(agent_id, message) + click.echo(json.dumps(_platform_task_summary(task), ensure_ascii=False)) + + asyncio.run(_run()) + + +@a2a.command("status", context_settings=_HELP) +@click.option("--space-id", default=None, help="Task 创建时使用的 A2A Space ID") +@click.argument("task_id") +def status(space_id: str | None, task_id: str): + """查询某 A2A Task 的状态。""" + + async def _run() -> None: + client = _space_client(space_id) + task = await client.get_task(task_id) + click.echo(json.dumps(_platform_task_summary(task), ensure_ascii=False)) + asyncio.run(_run()) -def _detect_project(agent_path: Path): - configs.setup_environment(agent_path) + +@a2a.command("cancel", context_settings=_HELP) +@click.option("--space-id", default=None, help="Task 创建时使用的 A2A Space ID") +@click.argument("task_id") +def cancel(space_id: str | None, task_id: str): + """取消某 A2A Task。""" + + async def _run() -> None: + client = _space_client(space_id) + task = await client.cancel(task_id) + click.echo(json.dumps(_platform_task_summary(task), ensure_ascii=False)) + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# 辅助(与旧版一致,框架无关) +# --------------------------------------------------------------------------- + + +def _platform_task_summary(task: A2APlatformTask) -> dict[str, str | None]: + remote_task = task.remote_task + remote_status = getattr(remote_task, "status", None) + return { + "task_id": task.id, + "status": TaskState.Name(remote_status.state) if remote_status is not None else None, + } + + +def _detect_project(agent_path: Path, *, configure_environment: bool = True): + if configure_environment: + configs.setup_environment(agent_path) result = FrameworkDetector(str(agent_path)).detect() if result.type.value == "unknown": raise click.ClickException("未检测到支持的框架 (LangChain/LangGraph/DeepAgents/ADK)") @@ -130,6 +321,16 @@ def _load_runner(agent_path: Path, *, no_trace: bool): 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) + + def _setup_tracing(framework_type: str) -> None: try: import os @@ -144,24 +345,7 @@ def _setup_tracing(framework_type: str) -> None: ) setup_tracing( enable_inmemory=True, - enable_langfuse=None, use_callback_only=use_callback_only, ) except Exception: return - - -def _build_agent_card_payload( - *, - app_name: str, - url: str, - description: str, - skills: Sequence[str], -) -> dict: - card = AgentCardBuilder( - name=app_name, - url=url, - description=description, - skills=skills, - ).build() - return card.model_dump(mode="json") diff --git a/ksadk/cli/cmd_agent.py b/ksadk/cli/cmd_agent.py index e1563630..0b8d68f0 100644 --- a/ksadk/cli/cmd_agent.py +++ b/ksadk/cli/cmd_agent.py @@ -2,9 +2,10 @@ from __future__ import annotations -import click from pathlib import Path +import click + from ksadk.cli.cmd_destroy import run_delete_command from ksadk.cli.cmd_invoke import run_invoke_command from ksadk.cli.cmd_status import run_status_command @@ -22,7 +23,9 @@ def agent(): @agent.command("list", context_settings=CONTEXT_SETTINGS) @pagination_options(default_page=1, default_size=20) @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @click.option("--framework", help="按框架过滤,支持逗号分隔多个值,如 langgraph,adk") @dry_run_option() @cli_output_option() @@ -59,7 +62,9 @@ def list_agents( @click.option("--watch", "-w", is_flag=True, help="Watch 模式,持续刷新") @click.option("--interval", "-i", default=2, help="Watch 刷新间隔 (秒)") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @dry_run_option() @cli_output_option() def status_agent( @@ -172,11 +177,20 @@ def invoke_agent( @agent.command("delete", context_settings=CONTEXT_SETTINGS) @click.argument("agent_refs", nargs=-1) -@click.option("--agent", "--agent-id", "agent_options", "-a", multiple=True, help="Agent 名称或 ID,可重复传入") +@click.option( + "--agent", + "--agent-id", + "agent_options", + "-a", + multiple=True, + help="Agent 名称或 ID,可重复传入", +) @click.option("--yes", "-y", "assume_yes", is_flag=True, help="跳过确认") @click.option("--force", "-f", "assume_yes", is_flag=True, hidden=True, help="(兼容) 跳过确认") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @dry_run_option() @cli_output_option() def delete_agent( diff --git a/ksadk/cli/cmd_build.py b/ksadk/cli/cmd_build.py index b3d4fecf..1120dc7c 100644 --- a/ksadk/cli/cmd_build.py +++ b/ksadk/cli/cmd_build.py @@ -1,23 +1,29 @@ """ agentengine build - 构建 Agent 应用 -支持两种模式: +支持三种模式: +- managed: 仅打包系统无关 runtime manifest - code: 打包 zip + 依赖 → 上传 KS3 (默认) - container: 构建 Docker 镜像 """ import asyncio -import click import time from datetime import datetime from pathlib import Path -from ksadk.cli.error_utils import abort_with_cli_error, is_debug_mode_enabled, remote_error, validation_error -from ksadk.cli.workflow_common import print_workflow_header, render_workflow_result + +import click +import yaml + +from ksadk.cli.error_utils import ( + abort_with_cli_error, + is_debug_mode_enabled, + remote_error, + validation_error, +) from ksadk.cli.ui import ( capture_standard_output, is_json_output, - output_option as cli_output_option, - print_error, print_info, print_kv, print_next_steps, @@ -25,6 +31,10 @@ print_success, print_warn, ) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) +from ksadk.cli.workflow_common import print_workflow_header, render_workflow_result @click.command(context_settings=dict(help_option_names=["-h", "--help"])) @@ -32,16 +42,32 @@ @click.option( "--mode", "-m", - type=click.Choice(["container", "code"]), - default="code", - help="构建模式: code (默认, zip+KS3) 或 container (Docker)", + type=click.Choice(["auto", "managed", "container", "code"]), + default="auto", + help="构建模式: auto (按配置推断)、managed、code 或 container", ) @click.option("--tag", "-t", help="镜像标签 (container 模式)") @click.option("--registry", help="镜像仓库地址 (container 模式)") -@click.option("--push", is_flag=True, help="构建后推送 (镜像到仓库 / zip到KS3)") -@click.option("--no-cache", is_flag=True, help="强制重新构建,不使用缓存 (code: 忽略已有 zip;container: docker --no-cache)") -@click.option("--repackage", is_flag=True, help="Code 模式复用依赖缓存,但强制重新打包当前代码/runtime") -@click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="KS3 区域 (code 模式)") +@click.option( + "--push", + is_flag=True, + help="构建后推送(仅 Container 镜像或 Code zip;ManagedRuntime 不支持)", +) +@click.option( + "--no-cache", + is_flag=True, + help="强制重新构建,不使用缓存 (code: 忽略已有 zip;container: docker --no-cache)", +) +@click.option( + "--repackage", is_flag=True, help="Code 模式复用依赖缓存,但强制重新打包当前代码/runtime" +) +@click.option( + "--region", + "-r", + default="cn-beijing-6", + envvar="KSYUN_REGION", + help="Code 的 KS3 区域,或 ManagedRuntime catalog 查询区域", +) @click.option("--ks3-bucket", help="KS3 bucket 名称 (code 模式, 默认: agentengine-{region})") @cli_output_option() def build( @@ -63,7 +89,9 @@ def build( \b 模式: - code: 打包 zip + 依赖,上传 KS3 (默认) + auto: 按 artifact_type 推断 (默认) + managed: 只打包 ManagedRuntime manifest + code: 打包 zip + 依赖,上传 KS3 container: 构建 Docker 镜像 \b @@ -77,22 +105,29 @@ def build( """ _ = output_mode agent_path = Path(agent_dir).resolve() + effective_mode = _resolve_build_mode(agent_path, mode) try: with capture_standard_output(): print_workflow_header( title="Agent 构建", - subtitle=f"mode: {mode}", + subtitle=f"mode: {effective_mode}", project_dir=agent_path, target="build", - region=region if mode == "code" else None, + region=region if effective_mode in {"code", "managed"} else None, mode_label="构建模式", - mode_value=mode, + mode_value=effective_mode, ) - if mode == "container": + if effective_mode == "container": result = _build_container(agent_path, tag, registry, push, no_cache) + elif effective_mode == "managed": + result = asyncio.run( + _build_managed_runtime(agent_path, push, region, ks3_bucket) + ) else: - result = asyncio.run(_build_code(agent_path, push, region, ks3_bucket, no_cache, repackage)) + result = asyncio.run( + _build_code(agent_path, push, region, ks3_bucket, no_cache, repackage) + ) except Exception as e: if is_debug_mode_enabled(): raise @@ -103,6 +138,91 @@ def build( render_workflow_result(action="build", result=result) +def _load_build_config(agent_path: Path) -> dict: + config_path = agent_path / "agentengine.yaml" + if not config_path.exists(): + config_path = agent_path / "ksadk.yaml" + if not config_path.exists(): + return {} + with config_path.open(encoding="utf-8-sig") as stream: + loaded = yaml.safe_load(stream) + return loaded if isinstance(loaded, dict) else {} + + +def _resolve_build_mode(agent_path: Path, requested_mode: str) -> str: + normalized = str(requested_mode or "auto").strip().lower() + if normalized != "auto": + return normalized + config = _load_build_config(agent_path) + artifact_type = str(config.get("artifact_type") or "").strip().lower() + return "managed" if artifact_type == "managedruntime" else "code" + + +async def _build_managed_runtime( + agent_path: Path, + push: bool, + region: str, + ks3_bucket: str | None = None, +): + """Build a local, system-independent runtime manifest for review/audit. + + ManagedRuntime deployment is an inline server contract. Its optional local + zip is deliberately never published to KS3; the server receives the + normalized manifest and resolves the managed image itself. + """ + import json + from dataclasses import asdict + + from ksadk.builders import ManagedRuntimeBuilder + from ksadk.managed_runtime import resolve_managed_runtime + + if push or ks3_bucket: + raise validation_error( + "ManagedRuntime 不使用 KS3;请移除 --push 和 --ks3-bucket。" + "构建出的本地 zip 仅用于审计,直接执行 `ksadk deploy .` 即可部署。" + ) + + config = _load_build_config(agent_path) + resolved = await resolve_managed_runtime(config, region=region) + builder = ManagedRuntimeBuilder(agent_path, runtime_version=resolved.version) + result = builder.build() + if not result.success or result.artifact_path is None: + raise validation_error(result.error_message or "ManagedRuntime manifest 构建失败") + + metadata_file = agent_path / ".agentengine" / "build-metadata.json" + metadata_file.parent.mkdir(parents=True, exist_ok=True) + data = asdict(result) + metadata_file.write_text( + json.dumps( + data, + ensure_ascii=False, + indent=2, + default=lambda value: str(value) if isinstance(value, Path) else value, + ), + encoding="utf-8", + ) + + _print_summary("ManagedRuntime", result, show_next_step=True) + return { + "framework": str(result.metadata.get("framework") or "codex"), + "artifact_type": "managedruntime", + "artifact_source": "built", + "artifact_reused": False, + "artifact_built": True, + "artifact_reference": str(result.artifact_path), + "artifact_path": str(result.artifact_path), + "artifact_size_mb": float(result.artifact_size_mb), + "ks3_path": "", + "ks3_public_url": "", + "ks3_internal_url": "", + "runtime_name": resolved.name, + "runtime_version": resolved.version, + "runtime_version_source": resolved.source, + "manifest_sha256": str(result.metadata.get("manifest_sha256") or ""), + "push": False, + } + + def _build_container(agent_path: Path, tag: str, registry: str, push: bool, no_cache: bool): """Container 模式构建""" from ksadk.builders import ContainerBuilder @@ -126,7 +246,10 @@ def _build_container(agent_path: Path, tag: str, registry: str, push: bool, no_c ) print_next_steps( - [f"agentengine deploy --target serverless --image {result.metadata['image']} --artifact-type Container"] + [ + "agentengine deploy --target serverless " + f"--image {result.metadata['image']} --artifact-type Container" + ] ) # 摘要 @@ -147,14 +270,16 @@ async def _build_code( agent_path: Path, push: bool, region: str, - ks3_bucket: str = None, + ks3_bucket: str | None = None, no_cache: bool = False, repackage: bool = False, ): """Code 模式构建""" from ksadk.builders import CodeBuilder, KS3Uploader - builder = CodeBuilder(project_dir=agent_path, config={"no_cache": no_cache, "repackage": repackage}) + builder = CodeBuilder( + project_dir=agent_path, config={"no_cache": no_cache, "repackage": repackage} + ) result = builder.build() if not result.success: @@ -167,17 +292,19 @@ async def _build_code( ks3_internal_url = None if push: + if result.artifact_path is None: + raise validation_error("构建成功但未生成代码包路径") print_rule("上传到 KS3") upload_started_at = time.monotonic() - + # 预发特殊逻辑: region 为 pre-online 时,资源上传到 cn-beijing-6 upload_region = "cn-beijing-6" if region == "pre-online" else region - + if region == "pre-online": print_warn("预发环境: 资源将上传到 cn-beijing-6 region") - + uploader = KS3Uploader(region=upload_region, bucket=ks3_bucket) - + # 使用时间戳确保每次上传的代码包路径唯一,支持真正的版本回滚 timestamp = datetime.now().strftime("%Y%m%d%H%M%S") object_key = f"agents/{agent_name}/code_{timestamp}.zip" @@ -189,7 +316,7 @@ async def _build_code( # 更新 metadata 以便持久化 result.metadata["ks3_path"] = ks3_path result.metadata["pushed"] = True - + ks3_public_url = uploader.get_public_url_by_key(object_key) ks3_internal_url = uploader.get_internal_url_by_key(object_key) print_kv("公网地址", ks3_public_url or "-") @@ -206,7 +333,7 @@ async def _build_code( try: import json from dataclasses import asdict - + # 自定义序列化: 处理 Path 对象 def default_serializer(obj): if isinstance(obj, Path): @@ -228,7 +355,9 @@ def default_serializer(obj): return { "framework": result.metadata.get("framework", "unknown"), "artifact_type": "code", - "artifact_source": "built_and_uploaded" if push and result.metadata.get("ks3_path") else "built", + "artifact_source": ( + "built_and_uploaded" if push and result.metadata.get("ks3_path") else "built" + ), "artifact_reused": bool(result.metadata.get("reused", False)), "artifact_built": True, "artifact_reference": str(result.metadata.get("ks3_path") or result.artifact_path or ""), diff --git a/ksadk/cli/cmd_completion.py b/ksadk/cli/cmd_completion.py index 552d60da..3aad3cc9 100644 --- a/ksadk/cli/cmd_completion.py +++ b/ksadk/cli/cmd_completion.py @@ -7,8 +7,10 @@ import os import re import sys -import click from pathlib import Path + +import click + from ksadk.cli.error_utils import ensure_json_output_supported, print_exception from ksadk.cli.resource_common import CONTEXT_SETTINGS from ksadk.cli.ui import ( @@ -63,7 +65,12 @@ def _resolve_bash_rc_file(home: Path) -> Path: def _has_zsh_compinit(rc_content: str) -> bool: - return re.search(r"(?m)^\s*(?:autoload\s+-Uz\s+compinit(?:\s*&&\s*compinit)?|compinit)\s*$", rc_content) is not None + return ( + re.search( + r"(?m)^\s*(?:autoload\s+-Uz\s+compinit(?:\s*&&\s*compinit)?|compinit)\s*$", rc_content + ) + is not None + ) def _rewrite_zsh_rc(rc_content: str, completion_file: Path, init_line: str) -> tuple[str, bool]: @@ -72,7 +79,9 @@ def _rewrite_zsh_rc(rc_content: str, completion_file: Path, init_line: str) -> t # 清理旧的 AgentEngine 自动补全配置,避免重复 source / eval。 updated = re.sub( - r'(?ms)^\s*if command -v agentengine >/dev/null 2>&1; then\s*\n\s*eval "\$\(_AGENTENGINE_COMPLETE=zsh_source agentengine\)"\s*\n\s*fi\s*\n?', + r"(?ms)^\s*if command -v agentengine >/dev/null 2>&1; then\s*\n" + r'\s*eval "\$\(_AGENTENGINE_COMPLETE=zsh_source agentengine\)"\s*\n' + r"\s*fi\s*\n?", "", rc_content, ) @@ -87,7 +96,7 @@ def _rewrite_zsh_rc(rc_content: str, completion_file: Path, init_line: str) -> t updated, ) updated = re.sub( - r'(?m)^\s*# AgentEngine CLI 自动补全\s*$\n?', + r"(?m)^\s*# AgentEngine CLI 自动补全\s*$\n?", "", updated, ) @@ -116,9 +125,9 @@ def _rewrite_bash_rc(rc_content: str, completion_file: Path) -> str: rc_content, ) updated = re.sub( - r'(?m)^\s*if command -v agentengine >/dev/null 2>&1; then\s*$\n?' + r"(?m)^\s*if command -v agentengine >/dev/null 2>&1; then\s*$\n?" r'^\s*eval "\$\(_AGENTENGINE_COMPLETE=bash_source agentengine\)"\s*$\n?' - r'^\s*fi\s*$\n?', + r"^\s*fi\s*$\n?", "", updated, ) @@ -128,7 +137,7 @@ def _rewrite_bash_rc(rc_content: str, completion_file: Path) -> str: updated, ) updated = re.sub( - r'(?m)^\s*# AgentEngine CLI 自动补全\s*$\n?', + r"(?m)^\s*# AgentEngine CLI 自动补全\s*$\n?", "", updated, ) @@ -140,7 +149,7 @@ def _rewrite_bash_rc(rc_content: str, completion_file: Path) -> str: @completion.command("bash", context_settings=CONTEXT_SETTINGS) def completion_bash(): """输出 Bash 补全脚本""" - script = ''' + script = """ _agentengine_completion() { local IFS=$'\\n' local line @@ -165,14 +174,14 @@ def completion_bash(): } complete -o default -F _agentengine_completion agentengine -''' +""" click.echo(script.strip()) @completion.command("zsh", context_settings=CONTEXT_SETTINGS) def completion_zsh(): """输出 Zsh 补全脚本""" - script = ''' + script = """ #compdef agentengine _agentengine() { @@ -201,18 +210,22 @@ def completion_zsh(): } compdef _agentengine agentengine -''' +""" click.echo(script.strip()) @completion.command("install", context_settings=CONTEXT_SETTINGS) -@click.option("--shell", type=click.Choice(["bash", "zsh", "auto"]), default="auto", - help="指定 Shell 类型") +@click.option( + "--shell", type=click.Choice(["bash", "zsh", "auto"]), default="auto", help="指定 Shell 类型" +) def completion_install(shell: str): """自动安装补全脚本到 Shell 配置文件""" ensure_json_output_supported( "agentengine completion install", - suggestion="请直接使用 `agentengine completion bash` 或 `agentengine completion zsh` 获取脚本内容。", + suggestion=( + "请直接使用 `agentengine completion bash` 或 `agentengine completion zsh` " + "获取脚本内容。" + ), ) print_title("安装自动补全") @@ -224,59 +237,57 @@ def completion_install(shell: str): print_info("请使用 --shell=bash 或 --shell=zsh 指定") return shell = resolved_shell - + home = Path.home() - + if shell == "zsh": rc_file = home / ".zshrc" completion_file = home / ".agentengine-complete.zsh" - completion_cmd = '_AGENTENGINE_COMPLETE=zsh_source agentengine' + completion_cmd = "_AGENTENGINE_COMPLETE=zsh_source agentengine" init_line = "autoload -Uz compinit && compinit" else: # bash rc_file = _resolve_bash_rc_file(home) completion_file = home / ".agentengine-complete.bash" - completion_cmd = '_AGENTENGINE_COMPLETE=bash_source agentengine' + completion_cmd = "_AGENTENGINE_COMPLETE=bash_source agentengine" init_line = None - + print_kv("目标 Shell", shell, value_style="#58a6ff") - print_kv("配置文件", rc_file, value_style="#58a6ff") + print_kv("配置文件", str(rc_file), value_style="#58a6ff") print_info("正在安装补全脚本...") - + # 生成补全脚本 try: import subprocess + env = os.environ.copy() env["_AGENTENGINE_COMPLETE"] = f"{shell}_source" - + result = subprocess.run( - [sys.executable, "-m", "ksadk.cli"], - env=env, - capture_output=True, - text=True + [sys.executable, "-m", "ksadk.cli"], env=env, capture_output=True, text=True ) - + completion_script = result.stdout - + if not completion_script.strip(): print_error("生成补全脚本失败") print_info("请尝试手动安装:") print_info(f"{completion_cmd} > {completion_file}") - print_info(f'echo \'source "{completion_file}"\' >> {rc_file}') + print_info(f"echo 'source \"{completion_file}\"' >> {rc_file}") return - + # 写入补全脚本文件 with open(completion_file, "w") as f: f.write(completion_script) - + print_success(f"补全脚本已保存到: {completion_file}") - + except Exception as e: print_exception("生成补全脚本失败", e) print_info("请尝试手动安装:") print_info(f"{completion_cmd} > {completion_file}") - print_info(f'echo \'source "{completion_file}"\' >> {rc_file}') + print_info(f"echo 'source \"{completion_file}\"' >> {rc_file}") return - + # 检查并更新 rc 文件 rc_content = "" if rc_file.exists(): @@ -284,6 +295,7 @@ def completion_install(shell: str): rc_content = f.read() if shell == "zsh": + assert init_line is not None updated_rc, added_compinit = _rewrite_zsh_rc(rc_content, completion_file, init_line) if added_compinit: print_success("已添加 compinit 初始化") @@ -297,7 +309,7 @@ def completion_install(shell: str): f.write(updated_rc) else: print_info("补全配置已存在,跳过") - + print_success("安装完成") print_info("请运行以下命令使其生效:") print_kv("命令", f"source {rc_file}", value_style="#58a6ff") diff --git a/ksadk/cli/cmd_config.py b/ksadk/cli/cmd_config.py index b4897adb..3f98f83f 100644 --- a/ksadk/cli/cmd_config.py +++ b/ksadk/cli/cmd_config.py @@ -1,11 +1,12 @@ -import os -from pathlib import Path import re +from pathlib import Path +from typing import Any + import click -from dotenv import dotenv_values import questionary -from questionary import Style import yaml +from dotenv import dotenv_values +from questionary import Style from ksadk.cli.error_utils import ( abort_with_cli_error, @@ -19,8 +20,6 @@ is_color_disabled, is_json_output, is_stdout_tty, - output_option as cli_output_option, - print_error, print_info, print_kv, print_rule, @@ -28,20 +27,25 @@ print_title, print_warn, ) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) # 自定义样式,确保选中项高亮可见 -custom_style = Style([ - ('qmark', 'fg:#5f819d bold'), # 略深一点的蓝青色 - ('question', 'bold'), - ('answer', 'fg:#69f0ae bold'), # 浅绿色 (替代深红) - ('pointer', 'fg:#fbc02d bold'), # 略深的金色/暗黄色 - ('highlighted', 'fg:#fbc02d bold'), # 同上 - ('selected', 'fg:#69f0ae'), # 浅绿色 - ('separator', 'fg:#69f0ae'), # 浅绿色 - ('instruction', ''), - ('text', ''), - ('disabled', 'fg:#858585 italic') -]) +custom_style = Style( + [ + ("qmark", "fg:#5f819d bold"), # 略深一点的蓝青色 + ("question", "bold"), + ("answer", "fg:#69f0ae bold"), # 浅绿色 (替代深红) + ("pointer", "fg:#fbc02d bold"), # 略深的金色/暗黄色 + ("highlighted", "fg:#fbc02d bold"), # 同上 + ("selected", "fg:#69f0ae"), # 浅绿色 + ("separator", "fg:#69f0ae"), # 浅绿色 + ("instruction", ""), + ("text", ""), + ("disabled", "fg:#858585 italic"), + ] +) def _questionary_style(): @@ -83,13 +87,13 @@ def _update_env_file(path: Path, updates: dict): for line in lines: stripped = line.strip() # Skip comments and empty lines - if not stripped or stripped.startswith('#'): + if not stripped or stripped.startswith("#"): new_lines.append(line) continue - + # Parse key=value - if '=' in stripped: - key = stripped.split('=', 1)[0].strip() + if "=" in stripped: + key = stripped.split("=", 1)[0].strip() if key in updates: new_lines.append(f"{key}={updates[key]}") updated_keys.add(key) @@ -104,7 +108,7 @@ def _update_env_file(path: Path, updates: dict): if key not in updated_keys and value: # Add a newline before new keys if the file wasn't empty and didn't end with one if not added_new and lines and lines[-1].strip(): - new_lines.append("") + new_lines.append("") new_lines.append(f"{key}={value}") added_new = True @@ -172,7 +176,9 @@ def _parse_set_items(set_items: tuple) -> tuple[dict, dict, list[str]]: return updates_yaml, updates_env, invalid_items -def _apply_set_command(set_items: tuple, output_path: Path, env_path: Path, is_global: bool) -> dict: +def _apply_set_command( + set_items: tuple, output_path: Path, env_path: Path, is_global: bool +) -> dict: """Apply non-interactive config mutations and return a structured summary.""" updates_yaml, updates_env, invalid_items = _parse_set_items(set_items) if not updates_yaml and not updates_env and invalid_items: @@ -206,11 +212,11 @@ def _apply_set_command(set_items: tuple, output_path: Path, env_path: Path, is_g if is_global: from ksadk.configs.global_config import ( build_global_config_from_env, - get_global_config_path + get_env_from_global_config, + get_global_config_path, + save_global_config, ) - from ksadk.configs.global_config import get_env_from_global_config, save_global_config - current_global_env = get_env_from_global_config() current_global_env.update(updates_env) if "region" in updates_yaml and "KSYUN_REGION" not in current_global_env: @@ -223,7 +229,11 @@ def _apply_set_command(set_items: tuple, output_path: Path, env_path: Path, is_g def _collect_config_snapshot(output_path: Path, env_path: Path) -> dict: - from ksadk.configs.global_config import get_env_from_global_config, get_global_config_path, load_global_config + from ksadk.configs.global_config import ( + get_env_from_global_config, + get_global_config_path, + load_global_config, + ) project_config = _load_yaml_file(output_path) project_env = {k: v for k, v in _load_env_file(env_path).items() if v not in (None, "")} @@ -307,7 +317,9 @@ def _render_config_set_result_pretty(result: dict) -> None: print_kv("全局配置", result["global_config_path"] or "~/.agentengine/settings.json") -def _run_config_set_command(*, set_items: tuple, output_path: Path, env_path: Path, is_global: bool) -> dict: +def _run_config_set_command( + *, set_items: tuple, output_path: Path, env_path: Path, is_global: bool +) -> dict: if not set_items: raise usage_error( "请至少提供一个 KEY=VALUE 配置项。", @@ -315,14 +327,15 @@ def _run_config_set_command(*, set_items: tuple, output_path: Path, env_path: Pa ) return _apply_set_command(set_items, output_path, env_path, is_global) + def run_config_wizard(config_file: str | None, set_items: tuple, is_global: bool): """通过交互式向导配置 agentengine.yaml 和 .env 文件 - + 支持: 1. 配置 Agent 基础信息 (名称、框架等) 2. 配置 模型服务 (API Key, Base URL) 3. 配置 云厂商凭证 (KSYUN AK/SK) - + 参数: --set: 非交互式设置配置项 (如 --set name=MyAgent --set KSYUN_REGION=cn-beijing-6) --global: 强制更新全局配置 (~/.agentengine/settings.json) @@ -332,7 +345,10 @@ def run_config_wizard(config_file: str | None, set_items: tuple, is_global: bool # === 0. 处理 --set 非交互模式 === if set_items: - print_warn("`agentengine config wizard --set ...` 是兼容入口,推荐改用 `agentengine config set KEY=VALUE ...`。") + print_warn( + "`agentengine config wizard --set ...` 是兼容入口," + "推荐改用 `agentengine config set KEY=VALUE ...`。" + ) result = _run_config_set_command( set_items=set_items, output_path=output_path, @@ -347,7 +363,10 @@ def run_config_wizard(config_file: str | None, set_items: tuple, is_global: bool ensure_json_output_supported( "agentengine config wizard", - suggestion="请改用 `agentengine config show --output json` 或 `agentengine config set KEY=VALUE`。", + suggestion=( + "请改用 `agentengine config show --output json` 或 " + "`agentengine config set KEY=VALUE`。" + ), ) _ensure_interactive_command( "agentengine config wizard", @@ -359,11 +378,11 @@ def run_config_wizard(config_file: str | None, set_items: tuple, is_global: bool print_title("AgentEngine 配置向导") # === 1. 加载现有配置 === - existing_config = {} + existing_config: dict[str, Any] = {} if output_path.exists(): try: # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 - with open(output_path, 'r', encoding='utf-8-sig') as f: + with open(output_path, "r", encoding="utf-8-sig") as f: existing_config = yaml.safe_load(f) or {} print_info(f"检测到现有配置文件: {output_path}") except Exception: @@ -374,7 +393,7 @@ def run_config_wizard(config_file: str | None, set_items: tuple, is_global: bool print_info(f"检测到现有环境变量: {env_path}") print_rule() - + # Helper to clean code and handle Ctrl+C def _ask_or_exit(question): result = question.ask() @@ -387,175 +406,199 @@ def _ask_or_exit(question): # === 2. 基础配置 (agentengine.yaml) === print_rule("基础配置") - + # 智能默认值: 优先读文件 -> 其次用目录名 -> 最后 my-agent - default_name = existing_config.get('name') + default_name = existing_config.get("name") if not default_name: default_name = Path.cwd().name - - new_config['name'] = _ask_or_exit(questionary.text( - "Agent 名称:", - default=default_name, - style=_questionary_style() - )) - - new_config['description'] = _ask_or_exit(questionary.text( - "Agent 描述:", - default=existing_config.get('description', ''), - style=_questionary_style() - )) - - frameworks = ['langgraph', 'langchain', 'deepagents', 'adk', 'openclaw'] - default_framework = existing_config.get('framework', 'langgraph') + + new_config["name"] = _ask_or_exit( + questionary.text("Agent 名称:", default=default_name, style=_questionary_style()) + ) + + new_config["description"] = _ask_or_exit( + questionary.text( + "Agent 描述:", + default=existing_config.get("description", ""), + style=_questionary_style(), + ) + ) + + frameworks = ["langgraph", "langchain", "deepagents", "adk", "openclaw"] + default_framework = existing_config.get("framework", "langgraph") if default_framework and default_framework not in frameworks: frameworks.append(default_framework) - new_config['framework'] = _ask_or_exit(questionary.select( - "选择开发框架:", - choices=frameworks, - default=default_framework, - style=_questionary_style() - )) - + new_config["framework"] = _ask_or_exit( + questionary.select( + "选择开发框架:", + choices=frameworks, + default=default_framework, + style=_questionary_style(), + ) + ) + print_rule() # === 3. 模型配置 (.env) === print_rule("模型配置") print_info("配置用于推理的大模型服务 (OpenAI 兼容接口)") - + # 向后兼容: 如果是从旧版模板生成的,可能包含 'your-api-key-here' 占位符,视为空 - default_api_key = existing_env.get('OPENAI_API_KEY', '') + default_api_key = existing_env.get("OPENAI_API_KEY", "") if default_api_key == "your-api-key-here": default_api_key = "" - new_env['OPENAI_API_KEY'] = _ask_or_exit(questionary.password( - "API Key (OPENAI_API_KEY):", - default=default_api_key, - style=_questionary_style() - )) - - new_env['OPENAI_BASE_URL'] = _ask_or_exit(questionary.text( - "Base URL (OPENAI_BASE_URL) [选填,默认使用金山云星流平台URL]:", - default=existing_env.get('OPENAI_BASE_URL', ''), - style=_questionary_style() - )) - - new_env['OPENAI_MODEL_NAME'] = _ask_or_exit(questionary.text( - "模型名称 (OPENAI_MODEL_NAME) [选填,默认使用金山云星流平台glm-5.2]:", - default=existing_env.get('OPENAI_MODEL_NAME', ''), - style=_questionary_style() - )) - + new_env["OPENAI_API_KEY"] = _ask_or_exit( + questionary.password( + "API Key (OPENAI_API_KEY):", default=default_api_key, style=_questionary_style() + ) + ) + + new_env["OPENAI_BASE_URL"] = _ask_or_exit( + questionary.text( + "Base URL (OPENAI_BASE_URL) [选填,默认使用金山云星流平台URL]:", + default=existing_env.get("OPENAI_BASE_URL", ""), + style=_questionary_style(), + ) + ) + + new_env["OPENAI_MODEL_NAME"] = _ask_or_exit( + questionary.text( + "模型名称 (OPENAI_MODEL_NAME) [选填,默认使用金山云星流平台glm-5.2]:", + default=existing_env.get("OPENAI_MODEL_NAME", ""), + style=_questionary_style(), + ) + ) + print_rule() # === 4. 云厂商配置 (.env) === print_rule("金山云配置 (可选)") print_info("用于 agentengine deploy 部署到云端环境") - - should_config_ksyun = _ask_or_exit(questionary.confirm( - "是否配置金山云凭证?", - default=bool(existing_env.get('KSYUN_ACCESS_KEY')), - style=_questionary_style() - )) + + should_config_ksyun = _ask_or_exit( + questionary.confirm( + "是否配置金山云凭证?", + default=bool(existing_env.get("KSYUN_ACCESS_KEY")), + style=_questionary_style(), + ) + ) if should_config_ksyun: - new_env['KSYUN_ACCESS_KEY'] = _ask_or_exit(questionary.password( - "Access Key (AK):", - default=existing_env.get('KSYUN_ACCESS_KEY', ''), - style=_questionary_style() - )) - - new_env['KSYUN_SECRET_KEY'] = _ask_or_exit(questionary.password( - "Secret Key (SK):", - default=existing_env.get('KSYUN_SECRET_KEY', ''), - style=_questionary_style() - )) - - new_env['KSYUN_ACCOUNT_ID'] = _ask_or_exit(questionary.text( - "Account ID (账户ID):", - default=existing_env.get('KSYUN_ACCOUNT_ID', ''), - style=_questionary_style() - )) - - - + new_env["KSYUN_ACCESS_KEY"] = _ask_or_exit( + questionary.password( + "Access Key (AK):", + default=existing_env.get("KSYUN_ACCESS_KEY", ""), + style=_questionary_style(), + ) + ) + + new_env["KSYUN_SECRET_KEY"] = _ask_or_exit( + questionary.password( + "Secret Key (SK):", + default=existing_env.get("KSYUN_SECRET_KEY", ""), + style=_questionary_style(), + ) + ) + + new_env["KSYUN_ACCOUNT_ID"] = _ask_or_exit( + questionary.text( + "Account ID (账户ID):", + default=existing_env.get("KSYUN_ACCOUNT_ID", ""), + style=_questionary_style(), + ) + ) + # 内部默认区域逻辑 - default_region = existing_env.get('KSYUN_REGION') + default_region = existing_env.get("KSYUN_REGION") if not default_region: - default_region = existing_config.get('region', 'cn-beijing-6') - + default_region = existing_config.get("region", "cn-beijing-6") + # 标准区域列表 - standard_regions = ['cn-beijing-6', 'cn-guangzhou-1'] + standard_regions = ["cn-beijing-6", "cn-guangzhou-1"] CUSTOM_OPTION = "⚙️ Custom (手动输入,金山云自定义区域请参考文档确认是否支持)" - + choices = standard_regions.copy() - - # Idempotency: 如果现有值是自定义的(比如 pre-online),加入列表作为默认选项,防止报错且方便确认 + + # 自定义现有值(如 pre-online)也加入选项,避免默认值校验失败。 if default_region and default_region not in standard_regions: choices.append(default_region) - + choices.append(CUSTOM_OPTION) - - selected_region = _ask_or_exit(questionary.select( - "默认区域 (Region):", - choices=choices, - default=default_region if default_region in choices else standard_regions[0], - style=_questionary_style() - )) - + + selected_region = _ask_or_exit( + questionary.select( + "默认区域 (Region):", + choices=choices, + default=default_region if default_region in choices else standard_regions[0], + style=_questionary_style(), + ) + ) + # 如果选择了自定义,或者是点击了之前保留的自定义值,这里逻辑是这样的: # 1. 如果选了标准值 -> 直接用 # 2. 如果选了 CUSTOM_OPTION -> 弹框让用户输 # 3. 如果选了列表里已有的自定义值 (如 pre-online) -> 直接用 - + if selected_region == CUSTOM_OPTION: - selected_region = _ask_or_exit(questionary.text( - "请输入区域 Code (如 cn-shanghai-2):", - default=default_region if default_region not in standard_regions else "", - style=_questionary_style() - )) - - new_env['KSYUN_REGION'] = selected_region - + selected_region = _ask_or_exit( + questionary.text( + "请输入区域 Code (如 cn-shanghai-2):", + default=default_region if default_region not in standard_regions else "", + style=_questionary_style(), + ) + ) + + new_env["KSYUN_REGION"] = selected_region + # 同步回 agentengine.yaml 的 region 字段,保持一致 - new_config['region'] = new_env['KSYUN_REGION'] + new_config["region"] = new_env["KSYUN_REGION"] else: # 如果不配置,保留原值或设为默认 - new_config['region'] = existing_config.get('region', 'cn-beijing-6') + new_config["region"] = existing_config.get("region", "cn-beijing-6") print_rule() # === 4.5 容器镜像仓库认证 (仅 container 模式需要) === print_rule("容器镜像部署 (可选)") print_info("如果计划使用 container 模式 (agentengine build -m container),需要配置镜像仓库认证") - - should_config_registry = _ask_or_exit(questionary.confirm( - "是否使用 container 模式部署?", - default=bool(existing_env.get('KCR_USERNAME')), - style=_questionary_style() - )) + + should_config_registry = _ask_or_exit( + questionary.confirm( + "是否使用 container 模式部署?", + default=bool(existing_env.get("KCR_USERNAME")), + style=_questionary_style(), + ) + ) if should_config_registry: - new_env['KCR_USERNAME'] = _ask_or_exit(questionary.text( - "KCR 用户名 (企业版请填写访问凭证用户名):", - default=existing_env.get('KCR_USERNAME', ''), - style=_questionary_style() - )) - - new_env['KCR_PASSWORD'] = _ask_or_exit(questionary.password( - "KCR 密码或 Token:", - default=existing_env.get('KCR_PASSWORD', ''), - style=_questionary_style() - )) - - default_registry = existing_env.get('KCR_REGISTRY', '') - custom_registry = _ask_or_exit(questionary.text( - "镜像仓库地址 [选填,如: agenthzzqy-vpc.ksyunkcr.com/testagent-pub]:", - default=default_registry, - style=_questionary_style() - )) - + new_env["KCR_USERNAME"] = _ask_or_exit( + questionary.text( + "KCR 用户名 (企业版请填写访问凭证用户名):", + default=existing_env.get("KCR_USERNAME", ""), + style=_questionary_style(), + ) + ) + + new_env["KCR_PASSWORD"] = _ask_or_exit( + questionary.password( + "KCR 密码或 Token:", + default=existing_env.get("KCR_PASSWORD", ""), + style=_questionary_style(), + ) + ) + + default_registry = existing_env.get("KCR_REGISTRY", "") + custom_registry = _ask_or_exit( + questionary.text( + "镜像仓库地址 [选填,如: agenthzzqy-vpc.ksyunkcr.com/testagent-pub]:", + default=default_registry, + style=_questionary_style(), + ) + ) + if custom_registry: - new_env['KCR_REGISTRY'] = custom_registry + new_env["KCR_REGISTRY"] = custom_registry print_info("提示:") print_info("个人版 KCR 可留空 KCR_USERNAME,运行时使用 KSYUN_ACCOUNT_ID 作为用户名兜底") @@ -563,77 +606,81 @@ def _ask_or_exit(question): print_info("KCR 访问凭证获取: https://kcr.console.ksyun.com/ → 访问凭证") print_rule() - + # === 5. 写入文件 === - + # 5.1 构造完整的 YAML (保留原有的其他配置) final_config = existing_config.copy() final_config.update(new_config) - + # 确保结构完整 (补全 config 命令中未询问但必须的字段,如果不存在的话) - if 'entry_point' not in final_config: - final_config['entry_point'] = f"{new_config['name'].replace('-', '_')}/agent.py" - if 'agent_variable' not in final_config: - final_config['agent_variable'] = "root_agent" - if 'version' not in final_config: - final_config['version'] = "1.0.0" + if "entry_point" not in final_config: + final_config["entry_point"] = f"{new_config['name'].replace('-', '_')}/agent.py" + if "agent_variable" not in final_config: + final_config["agent_variable"] = "root_agent" + if "version" not in final_config: + final_config["version"] = "1.0.0" # 5.2 写入 agentengine.yaml # 使用 utf-8-sig 编码 (带 BOM),确保 Windows 程序正确识别为 UTF-8 - with open(output_path, 'w', encoding='utf-8-sig') as f: + with open(output_path, "w", encoding="utf-8-sig") as f: # 简单的字典转 YAML 可能丢失注释,但这是预期行为 # 为了更好的体验,我们手动排版几个关键字段,其他用 dump - - f.write(f"# AgentEngine Project Config\n") + + f.write("# AgentEngine Project Config\n") f.write(f"name: {final_config['name']}\n") f.write(f"version: \"{final_config.get('version', '1.0.0')}\"\n\n") - - f.write(f"# Framework\n") + + f.write("# Framework\n") f.write(f"framework: {final_config['framework']}\n") f.write(f"entry_point: {final_config['entry_point']}\n") f.write(f"agent_variable: {final_config['agent_variable']}\n\n") - - f.write(f"# Deployment\n") + + f.write("# Deployment\n") f.write(f"region: {final_config.get('region', 'cn-beijing-6')}\n") - + # 处理其他复杂对象如 resources, scaling 等,如果存在 - remaining = {k: v for k, v in final_config.items() if k not in [ - 'name', 'version', 'framework', 'entry_point', 'agent_variable', 'region' - ]} + remaining = { + k: v + for k, v in final_config.items() + if k not in ["name", "version", "framework", "entry_point", "agent_variable", "region"] + } if remaining: f.write("\n# Advanced Settings\n") yaml.dump(remaining, f, default_flow_style=False) # 5.3 写入 .env _update_env_file(env_path, new_env) - + print_success("配置完成") print_kv("配置文件", str(output_path)) print_kv("环境凭证", str(env_path)) - + # 5.4 处理全局配置保存逻辑 print_rule() from ksadk.configs.global_config import ( - save_global_config, build_global_config_from_env, get_global_config_path, global_config_exists, + save_global_config, ) should_save_global = False - + # 情况1: 用户显式指定 --global -> 总是保存 (或确认后保存) if is_global: should_save_global = True - + # 情况2: 全局配置不存在 -> 首次运行,提示保存 elif not global_config_exists(): - should_save_global = _ask_or_exit(questionary.confirm( - "是否保存到全局配置 (后续新项目可自动复用)?", - default=True, - style=_questionary_style() - )) - + should_save_global = _ask_or_exit( + questionary.confirm( + "是否保存到全局配置 (后续新项目可自动复用)?", + default=True, + style=_questionary_style(), + ) + ) + # 情况3: 全局配置已存在 且 未指定 --global -> 静默跳过,不打扰用户 else: should_save_global = False @@ -647,8 +694,17 @@ def _ask_or_exit(question): @click.group("config", context_settings=CONTEXT_SETTINGS, invoke_without_command=True) -@click.option("--set", "-s", "set_items", multiple=True, hidden=True, help="(兼容) 设置配置项 key=value") -@click.option("--global", "is_global", is_flag=True, default=False, hidden=True, help="(兼容) 强制更新全局配置") +@click.option( + "--set", "-s", "set_items", multiple=True, hidden=True, help="(兼容) 设置配置项 key=value" +) +@click.option( + "--global", + "is_global", + is_flag=True, + default=False, + hidden=True, + help="(兼容) 强制更新全局配置", +) @cli_output_option(hidden=True) @click.pass_context def config(ctx: click.Context, set_items: tuple, is_global: bool, output_mode: str | None): @@ -662,7 +718,10 @@ def config(ctx: click.Context, set_items: tuple, is_global: bool, output_mode: s return if set_items: - print_warn("`agentengine config --set ...` 是兼容入口,推荐改用 `agentengine config set KEY=VALUE ...`。") + print_warn( + "`agentengine config --set ...` 是兼容入口," + "推荐改用 `agentengine config set KEY=VALUE ...`。" + ) result = _run_config_set_command( set_items=set_items, output_path=_default_project_config_path(), @@ -677,17 +736,27 @@ def config(ctx: click.Context, set_items: tuple, is_global: bool, output_mode: s ensure_json_output_supported( "agentengine config", - suggestion="请改用 `agentengine config show --output json` 或 `agentengine config set KEY=VALUE`。", + suggestion=( + "请改用 `agentengine config show --output json` 或 " + "`agentengine config set KEY=VALUE`。" + ), ) run_config_wizard(config_file=None, set_items=(), is_global=is_global) @config.command("wizard", context_settings=CONTEXT_SETTINGS) -@click.option("--file", "config_file", default=None, help="配置文件路径(默认自动复用 agentengine.yaml/ksadk.yaml)") -@click.option('--set', '-s', 'set_items', multiple=True, help='设置配置项 key=value') -@click.option('--global', 'is_global', is_flag=True, default=False, help='强制更新全局配置') +@click.option( + "--file", + "config_file", + default=None, + help="配置文件路径(默认自动复用 agentengine.yaml/ksadk.yaml)", +) +@click.option("--set", "-s", "set_items", multiple=True, help="设置配置项 key=value") +@click.option("--global", "is_global", is_flag=True, default=False, help="强制更新全局配置") @cli_output_option(hidden=True) -def config_wizard(config_file: str | None, set_items: tuple, is_global: bool, output_mode: str | None): +def config_wizard( + config_file: str | None, set_items: tuple, is_global: bool, output_mode: str | None +): """通过交互式向导配置项目。""" _ = output_mode run_config_wizard(config_file=config_file, set_items=set_items, is_global=is_global) diff --git a/ksadk/cli/cmd_create.py b/ksadk/cli/cmd_create.py index 8e528b15..1cef7ea0 100644 --- a/ksadk/cli/cmd_create.py +++ b/ksadk/cli/cmd_create.py @@ -7,11 +7,12 @@ import re import shlex import shutil - from pathlib import Path import click import questionary +from rich.markup import escape + from ksadk.cli.cmd_config import custom_style from ksadk.cli.ui import ( print_error, @@ -112,10 +113,10 @@ def _runtime_package_name_or_exit(project_name: str) -> str: def hello(name: str) -> dict: """问候工具 - + Args: name: 名字 - + Returns: 问候语 """ @@ -133,7 +134,7 @@ def hello(name: str) -> dict: }, "langchain": { "agent.py": '''""" -{package_name} - LangChain Agent +{package_name} - LangChain Agent (create_agent) """ import os @@ -142,9 +143,8 @@ def hello(name: str) -> dict: load_dotenv(Path(__file__).parent.parent / ".env") +from langchain.agents import create_agent from langchain_openai import ChatOpenAI -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.output_parsers import StrOutputParser llm = ChatOpenAI( model=os.getenv("OPENAI_MODEL_NAME", "glm-5.2"), @@ -153,12 +153,28 @@ def hello(name: str) -> dict: streaming=True, ) -prompt = ChatPromptTemplate.from_messages([ - ("system", "你是一个友好的助手。请用中文回复。"), - ("human", "{{input}}") -]) -root_agent = prompt | llm | StrOutputParser() +def hello(name: str) -> str: + """问候工具 + + Args: + name: 名字 + + Returns: + 问候语 + """ + return f"你好, {{name}}!" + + +# create_agent 是新版 LangChain 定义 agent 的推荐方式,自带工具调用; +# 可选 middleware=[HumanInTheLoopMiddleware(...)](人工审批 HITL)与 +# checkpointer(会话持久化)。detector 将本项目判为 LANGCHAIN, +# 经 LangChainRunner 运行。 +root_agent = create_agent( + model=llm, + tools=[hello], + system_prompt="你是一个友好的助手。请用中文回复。", +) ''', }, "langgraph": { @@ -243,42 +259,46 @@ def chat(state: State): def _detect_framework(content: str) -> str: """检测 Agent 文件使用的框架""" - if 'from deepagents' in content or 'import deepagents' in content or 'create_deep_agent(' in content: - return 'deepagents' - elif 'from langgraph' in content or 'import langgraph' in content: - return 'langgraph' - elif 'from google.adk' in content or 'import google.adk' in content: - return 'adk' - elif 'from langchain' in content or 'import langchain' in content: - return 'langchain' - return 'unknown' + if ( + "from deepagents" in content + or "import deepagents" in content + or "create_deep_agent(" in content + ): + return "deepagents" + elif "from langgraph" in content or "import langgraph" in content: + return "langgraph" + elif "from google.adk" in content or "import google.adk" in content: + return "adk" + elif "from langchain" in content or "import langchain" in content: + return "langchain" + return "unknown" def _detect_agent_variable(content: str) -> str | None: """检测 Agent 变量名""" import re - + # 优先级匹配: root_agent > compiled graph > StateGraph > Agent() # Only module-level exports are valid entry variables. Service-style # projects often build a local `graph` inside init_agent_resources(), which # must not be treated as importable module state. patterns = [ - (r'^(root_agent)\s*=', 'root_agent'), - (r'^(root_agent)\s*:\s*[^=]+\s*=', 'root_agent'), # type annotated root_agent - (r'^(\w+)\s*=\s*\w*graph\w*\.compile\(', None), # e.g., agent = graph.compile() - (r'^(\w+)\s*=\s*StateGraph', None), # e.g., graph = StateGraph(...) - (r'^(\w+)\s*=\s*Agent\(', None), # ADK: agent = Agent(...) - (r'^(\w+)\s*=\s*create_react_agent\(', None), # create_react_agent - (r'^(\w+)\s*=\s*create_deep_agent\(', None), # deepagents create_deep_agent - (r'^(\w+)\s*=\s*create_agent\(', None), # langchain create_agent - (r'^(\w+)\s*=\s*build_agent\(', None), # adapter style build_agent + (r"^(root_agent)\s*=", "root_agent"), + (r"^(root_agent)\s*:\s*[^=]+\s*=", "root_agent"), # type annotated root_agent + (r"^(\w+)\s*=\s*\w*graph\w*\.compile\(", None), # e.g., agent = graph.compile() + (r"^(\w+)\s*=\s*StateGraph", None), # e.g., graph = StateGraph(...) + (r"^(\w+)\s*=\s*Agent\(", None), # ADK: agent = Agent(...) + (r"^(\w+)\s*=\s*create_react_agent\(", None), # create_react_agent + (r"^(\w+)\s*=\s*create_deep_agent\(", None), # deepagents create_deep_agent + (r"^(\w+)\s*=\s*create_agent\(", None), # langchain create_agent + (r"^(\w+)\s*=\s*build_agent\(", None), # adapter style build_agent ] - + for pattern, fixed_name in patterns: match = re.search(pattern, content, re.MULTILINE | re.IGNORECASE) if match: return fixed_name if fixed_name else match.group(1) - + return None @@ -367,7 +387,11 @@ def _load_entry_from_langgraph_json(directory: Path) -> tuple[Path, str] | None: path_part = path_part.strip().removeprefix("./") agent_var = agent_var.strip() or "root_agent" entry_path = directory / Path(path_part.replace("\\", "/")) - if entry_path.exists() and entry_path.is_file() and _entry_exposes_variable(entry_path, agent_var): + if ( + entry_path.exists() + and entry_path.is_file() + and _entry_exposes_variable(entry_path, agent_var) + ): return entry_path, agent_var return None @@ -389,7 +413,7 @@ def _load_framework_from_agentengine_yaml(directory: Path) -> str | None: if not isinstance(framework, str): return None framework = framework.strip().lower() - if framework in {"adk", "langchain", "langgraph", "deepagents", "openclaw", "hermes"}: + if framework in {"adk", "langchain", "langgraph", "deepagents", "openclaw", "hermes", "codex"}: return framework return None @@ -501,35 +525,37 @@ def _fix_dotenv_paths_in_file(file_path: Path, depth: int = 1) -> bool: 返回是否修改了文件 """ import re - + try: - content = file_path.read_text(encoding='utf-8') + content = file_path.read_text(encoding="utf-8") except Exception: return False - + # 构建替换的 parent 链 - parent_chain = '.parent' * depth - + parent_chain = ".parent" * depth + # 匹配各种 load_dotenv 模式 patterns = [ # load_dotenv(Path(__file__).parent / ".env") - (r'(load_dotenv\s*\(\s*Path\s*\(\s*__file__\s*\)\s*)\.parent(\s*/\s*["\']\.env["\'])', - rf'\1{parent_chain}\2'), + ( + r'(load_dotenv\s*\(\s*Path\s*\(\s*__file__\s*\)\s*)\.parent(\s*/\s*["\']\.env["\'])', + rf"\1{parent_chain}\2", + ), # load_dotenv(Path(__file__).parent.parent / ".env") -> 根据 depth 调整 ] - + modified = False new_content = content - + for pattern, replacement in patterns: new_content_temp = re.sub(pattern, replacement, new_content) if new_content_temp != new_content: new_content = new_content_temp modified = True - + if modified: - file_path.write_text(new_content, encoding='utf-8') - + file_path.write_text(new_content, encoding="utf-8") + return modified @@ -539,76 +565,76 @@ def _fix_dotenv_paths_recursive(directory: Path, depth: int = 1) -> int: 返回修复的文件数量 """ fixed_count = 0 - - for py_file in directory.rglob('*.py'): + + for py_file in directory.rglob("*.py"): # 计算相对深度 rel_path = py_file.relative_to(directory) file_depth = len(rel_path.parts) + depth - 1 # 相对于项目根目录 - + if _fix_dotenv_paths_in_file(py_file, file_depth): fixed_count += 1 - + return fixed_count def _fix_nested_imports(file_path: Path, subdirs: list[str]) -> bool: """ 修复文件中的嵌套目录导入 - + 当源目录包含子包目录(如 my_agent/)时, 入口文件中的 `from my_agent import xxx` 需要改为 `from .my_agent import xxx` - + Args: file_path: 需要修复的文件 subdirs: 同级子目录名列表 - + Returns: 是否修改了文件 """ import re - + if not subdirs: return False - + try: - content = file_path.read_text(encoding='utf-8') + content = file_path.read_text(encoding="utf-8") except Exception: return False - + original = content - + # 为每个子目录生成修复规则 for subdir in subdirs: # from my_agent import xxx -> from .my_agent import xxx - pattern = rf'^(from\s+)({re.escape(subdir)})(\s+import\s+)' - replacement = rf'\1.\2\3' + pattern = rf"^(from\s+)({re.escape(subdir)})(\s+import\s+)" + replacement = r"\1.\2\3" content = re.sub(pattern, replacement, content, flags=re.MULTILINE) - + # import my_agent -> from . import my_agent # 只处理独立的 import 语句 - pattern_import = rf'^import\s+({re.escape(subdir)})\s*$' - replacement_import = rf'from . import \1' + pattern_import = rf"^import\s+({re.escape(subdir)})\s*$" + replacement_import = r"from . import \1" content = re.sub(pattern_import, replacement_import, content, flags=re.MULTILINE) - + if content != original: - file_path.write_text(content, encoding='utf-8') + file_path.write_text(content, encoding="utf-8") return True - + return False def _generate_env_content(global_env: dict) -> str: - """生成 .env 文件内容""" - api_key = global_env.get("OPENAI_API_KEY", "") + """Generate project-local configuration without copying credentials. + + ``init`` may read a global profile to retain non-secret endpoint defaults, but + it must not persist the caller's credentials in a newly created directory. + """ base_url = global_env.get("OPENAI_BASE_URL", "") model_name = global_env.get("OPENAI_MODEL_NAME", "") - ks_ak = global_env.get("KSYUN_ACCESS_KEY", "") - ks_sk = global_env.get("KSYUN_SECRET_KEY", "") ks_region = global_env.get("KSYUN_REGION", "cn-beijing-6") - ks_account = global_env.get("KSYUN_ACCOUNT_ID", "") - - env_content = f"""# 模型配置 -OPENAI_API_KEY={api_key} + + env_content = """# 模型配置 +# OPENAI_API_KEY= """ if base_url: env_content += f"OPENAI_BASE_URL={base_url}\n" @@ -618,110 +644,131 @@ def _generate_env_content(global_env: dict) -> str: env_content += f"OPENAI_MODEL_NAME={model_name}\n" else: env_content += "# OPENAI_MODEL_NAME=\n" - - env_content += f""" + + env_content += """ # 金山云配置 """ - if ks_ak: - env_content += f"KSYUN_ACCESS_KEY={ks_ak}\n" - else: - env_content += "# KSYUN_ACCESS_KEY=\n" - if ks_sk: - env_content += f"KSYUN_SECRET_KEY={ks_sk}\n" - else: - env_content += "# KSYUN_SECRET_KEY=\n" + env_content += "# KSYUN_ACCESS_KEY=\n" + env_content += "# KSYUN_SECRET_KEY=\n" env_content += f"KSYUN_REGION={ks_region}\n" - if ks_account: - env_content += f"KSYUN_ACCOUNT_ID={ks_account}\n" - else: - env_content += "# KSYUN_ACCOUNT_ID=\n" - + env_content += "# KSYUN_ACCOUNT_ID=\n" + return env_content +def _generate_codex_env_content(global_env: dict) -> str: + """Generate the intentionally small local-only environment for a Codex project. + + A ManagedRuntime manifest is declarative and must never acquire cloud, storage or + observability credentials merely because the developer ran ``init``. The three + OpenAI-compatible values are enough for native ``ksadk web`` debugging. The + optional proxy knobs are documented rather than copied from the global profile, + because their upstream key can be different from the model key. + """ + base_url = global_env.get("OPENAI_BASE_URL", "") + model_name = global_env.get("OPENAI_MODEL_NAME", "") + lines = [ + "# 本地 Codex 模型配置(仅用于 ksadk web,不会进入 ManagedRuntime bundle)", + "# OPENAI_API_KEY=", + f"OPENAI_BASE_URL={base_url}" if base_url else "# OPENAI_BASE_URL=", + f"OPENAI_MODEL_NAME={model_name}" if model_name else "# OPENAI_MODEL_NAME=glm-5.2", + "", + "# 可选:自定义上游仅支持 Chat Completions 时,强制启用协议转换代理", + "# KSADK_CODEX_USE_PROXY=1", + "# KSADK_PROXY_UPSTREAM_BASE=", + "# KSADK_PROXY_UPSTREAM_KEY=", + "", + ] + return "\n".join(lines) + + def _generate_requirements_from_imports(directory: Path, framework: str) -> str: """ 扫描目录中的 Python 文件,从 import 语句生成 requirements.txt """ import re - + # 常用包名到 PyPI 包名的映射 import_to_package = { - 'langchain': 'langchain', - 'langchain_openai': 'langchain-openai', - 'langchain_anthropic': 'langchain-anthropic', - 'langchain_core': 'langchain-core', - 'langchain_community': 'langchain-community', - 'langgraph': 'langgraph', - 'deepagents': 'deepagents', - 'openai': 'openai', - 'anthropic': 'anthropic', - 'dotenv': 'python-dotenv', - 'pydantic': 'pydantic', - 'httpx': 'httpx', - 'requests': 'requests', - 'google.adk': 'google-adk', - 'google.genai': 'google-genai', - 'tiktoken': 'tiktoken', - 'faiss': 'faiss-cpu', - 'chromadb': 'chromadb', - 'tavily': 'tavily-python', + "langchain": "langchain", + "langchain_openai": "langchain-openai", + "langchain_anthropic": "langchain-anthropic", + "langchain_core": "langchain-core", + "langchain_community": "langchain-community", + "langgraph": "langgraph", + "deepagents": "deepagents", + "openai": "openai", + "anthropic": "anthropic", + "dotenv": "python-dotenv", + "pydantic": "pydantic", + "httpx": "httpx", + "requests": "requests", + "google.adk": "google-adk", + "google.genai": "google-genai", + "tiktoken": "tiktoken", + "faiss": "faiss-cpu", + "chromadb": "chromadb", + "tavily": "tavily-python", } - + found_packages = set() - + # 扫描所有 .py 文件 - for py_file in directory.rglob('*.py'): + for py_file in directory.rglob("*.py"): try: - content = py_file.read_text(encoding='utf-8') + content = py_file.read_text(encoding="utf-8") except Exception: continue - + # 匹配 import 语句 # from xxx import yyy # import xxx import_patterns = [ - r'^from\s+([\w\.]+)', - r'^import\s+([\w\.]+)', + r"^from\s+([\w\.]+)", + r"^import\s+([\w\.]+)", ] - + for pattern in import_patterns: for match in re.finditer(pattern, content, re.MULTILINE): - module = match.group(1).split('.')[0] # 取顶级模块 + module = match.group(1).split(".")[0] # 取顶级模块 full_module = match.group(1) - + # 检查是否在映射中 if full_module in import_to_package: found_packages.add(import_to_package[full_module]) elif module in import_to_package: found_packages.add(import_to_package[module]) # 检查常见的下划线包名 - elif module.replace('_', '-') in ['langchain-openai', 'langchain-anthropic', - 'langchain-core', 'langchain-community']: - found_packages.add(module.replace('_', '-')) - + elif module.replace("_", "-") in [ + "langchain-openai", + "langchain-anthropic", + "langchain-core", + "langchain-community", + ]: + found_packages.add(module.replace("_", "-")) + # 确保框架相关的核心依赖在列表中 - if framework == 'langgraph': - found_packages.add('langgraph') - found_packages.add('langchain') - found_packages.add('langchain-openai') - elif framework == 'deepagents': - found_packages.add('deepagents') - found_packages.add('langgraph') - found_packages.add('langchain') - found_packages.add('langchain-openai') - elif framework == 'langchain': - found_packages.add('langchain') - found_packages.add('langchain-openai') - elif framework == 'adk': - found_packages.add('google-adk') - + if framework == "langgraph": + found_packages.add("langgraph") + found_packages.add("langchain") + found_packages.add("langchain-openai") + elif framework == "deepagents": + found_packages.add("deepagents") + found_packages.add("langgraph") + found_packages.add("langchain") + found_packages.add("langchain-openai") + elif framework == "langchain": + found_packages.add("langchain") + found_packages.add("langchain-openai") + elif framework == "adk": + found_packages.add("google-adk") + # 总是添加 python-dotenv - found_packages.add('python-dotenv') - + found_packages.add("python-dotenv") + # 按字母顺序排序 sorted_packages = sorted(found_packages) - return '\n'.join(sorted_packages) + '\n' + return "\n".join(sorted_packages) + "\n" def _analyze_langgraph_state(content: str) -> dict: @@ -752,7 +799,9 @@ def _analyze_langgraph_state(content: str) -> dict: preferred = ("query", "question", "prompt", "user_input", "input") input_field = next((field for field in preferred if field in candidates), None) - if candidates and ("TypedDict" in content or "StateGraph(" in content or "langgraph" in content): + if candidates and ( + "TypedDict" in content or "StateGraph(" in content or "langgraph" in content + ): return {"kind": "custom", "input_field": input_field} if "StateGraph(" in content or "from langgraph" in content or "import langgraph" in content: return {"kind": "ambiguous", "input_field": input_field} @@ -789,15 +838,15 @@ def _generate_langgraph_adapter_content( ) -> str: input_field = analysis.get("input_field") if input_field: - return_body = f''' return {{ + return_body = f""" return {{ "{input_field}": payload.get("input", ""), - }}''' + }}""" else: - return_body = ''' # TODO: Map AgentEngine's chat payload to your LangGraph State. + return_body = """ # TODO: Map AgentEngine's chat payload to your LangGraph State. # Common examples: # return {"query": payload.get("input", "")} # return {"question": payload.get("input", ""), "context": []} - return dict(payload)''' + return dict(payload)""" return f'''""" AgentEngine adapter generated for a LangGraph project. @@ -919,7 +968,9 @@ def _detect_deepagents_service_project( def _generate_deepagents_service_adapter_content(analysis: dict) -> str: init_module = analysis["init_module"] runnable_module = analysis.get("runnable_module") - runnable_module_constant = f'RUNNABLE_MODULE = "{runnable_module}"' if runnable_module else "RUNNABLE_MODULE = None" + runnable_module_constant = ( + f'RUNNABLE_MODULE = "{runnable_module}"' if runnable_module else "RUNNABLE_MODULE = None" + ) runnable_build = " return None\n" if runnable_module: runnable_build = """ try: @@ -1040,7 +1091,14 @@ async def ainvoke(self, payload: Any, config: dict | None = None, **kwargs: Any) return self._normalize_result(result) if hasattr(self._agent, "ainvoke"): - result = await self._agent.ainvoke({{"messages": payload.get("messages", [])}} if isinstance(payload, dict) and payload.get("messages") else payload, config=config, **kwargs) + agent_payload = ( + {{"messages": payload.get("messages", [])}} + if isinstance(payload, dict) and payload.get("messages") + else payload + ) + result = await self._agent.ainvoke( + agent_payload, config=config, **kwargs + ) return self._normalize_result(result) result = self._agent.invoke(payload, config=config, **kwargs) return self._normalize_result(result) @@ -1050,7 +1108,10 @@ def invoke(self, payload: Any, config: dict | None = None, **kwargs: Any) -> dic loop = asyncio.get_running_loop() except RuntimeError: return asyncio.run(self.ainvoke(payload, config=config, **kwargs)) - raise RuntimeError("Synchronous invoke cannot run while an event loop is already active; use ainvoke instead") + raise RuntimeError( + "Synchronous invoke cannot run while an event loop is already active; " + "use ainvoke instead" + ) def ksadk_prepare_state(payload: dict, session_context: dict) -> dict: @@ -1093,55 +1154,55 @@ def _wrap_agent_file(from_agent_path: Path, project_name: str, framework: str, a """包装单个 Agent 文件到新项目""" project_path = Path(project_name) package_name = _runtime_package_name_or_exit(project_path.name) - + if project_path.exists(): print_error(f"目录 '{project_name}' 已存在") raise SystemExit(1) - + print_title("包装 Agent 文件") print_kv("创建项目", project_name) print_kv("框架", framework) print_kv("包装文件", str(from_agent_path)) print_kv("Agent 变量", agent_var) - + # 创建目录 (project_path / package_name).mkdir(parents=True) - + # 复制源文件并修复 .env 路径 source_filename = from_agent_path.name dest_path = project_path / package_name / source_filename - + # 读取源文件内容 - source_content = from_agent_path.read_text(encoding='utf-8') - + source_content = from_agent_path.read_text(encoding="utf-8") + # 修复 load_dotenv 路径:parent -> parent.parent (因为文件被移到了子目录) fixed_content = re.sub( r'(load_dotenv\s*\(\s*Path\s*\(\s*__file__\s*\)\s*\.parent)(\s*/\s*["\']\.env["\'])', - r'\1.parent\2', - source_content + r"\1.parent\2", + source_content, ) - + if fixed_content != source_content: print_info("已自动修复 .env 加载路径") - + # 写入目标文件 - dest_path.write_text(fixed_content, encoding='utf-8') - + dest_path.write_text(fixed_content, encoding="utf-8") + # 检测全局配置 from ksadk.configs.global_config import ( - global_config_exists, get_env_from_global_config, + global_config_exists, ) - + global_env = {} if global_config_exists(): global_env = get_env_from_global_config() if global_env: - print_info("检测到全局配置,已自动填充凭证") - + print_info("检测到全局配置;项目 .env 仅保留非敏感默认值,凭证不会被复制") + # 生成 .env (project_path / ".env").write_text(_generate_env_content(global_env), encoding="utf-8-sig") - + # LangGraph custom-state 项目生成 adapter,避免直接猜业务 State 语义。 entry_relative = Path(source_filename) export_agent_var = agent_var @@ -1174,23 +1235,29 @@ def _wrap_agent_file(from_agent_path: Path, project_name: str, framework: str, a # 生成 agentengine.yaml entry_module = entry_relative.with_suffix("").name - (project_path / "agentengine.yaml").write_text(f"""# AgentEngine 项目配置 (Wrapped) + (project_path / "agentengine.yaml").write_text( + f"""# AgentEngine 项目配置 (Wrapped) name: {package_name} version: "1.0.0" framework: {framework} entry_point: {package_name}/{entry_relative} agent_variable: {export_agent_var} -""", encoding="utf-8-sig") - +""", + encoding="utf-8-sig", + ) + # 生成 __init__.py - (project_path / package_name / "__init__.py").write_text(f'''""" + (project_path / package_name / "__init__.py").write_text( + f'''""" {project_name} - Wrapped Agent """ from .{entry_module} import {export_agent_var} as root_agent __all__ = ["root_agent"] -''', encoding="utf-8-sig") - +''', + encoding="utf-8-sig", + ) + # 生成 requirements.txt reqs = "" if framework == "langchain": @@ -1202,9 +1269,10 @@ def _wrap_agent_file(from_agent_path: Path, project_name: str, framework: str, a elif framework == "adk": reqs = "google-adk\npython-dotenv\n" (project_path / "requirements.txt").write_text(reqs, encoding="utf-8") - + # 生成 README.md - (project_path / "README.md").write_text(f"""# {project_name} + (project_path / "README.md").write_text( + f"""# {project_name} Wrapped Agent project (from `{from_agent_path.name}`). @@ -1214,36 +1282,36 @@ def _wrap_agent_file(from_agent_path: Path, project_name: str, framework: str, a cd {project_name} agentengine run -i . ``` -""", encoding="utf-8-sig") - +""", + encoding="utf-8-sig", + ) + print_success("包装完成") print_rule("快速开始") _print_quick_start_commands(project_name, ["agentengine run -i ."]) -def _wrap_agent_directory(from_agent_dir: Path, project_name: str, framework: str, - entry_file: Path, agent_var: str): +def _wrap_agent_directory( + from_agent_dir: Path, project_name: str, framework: str, entry_file: Path, agent_var: str +): """包装 Agent 目录到新项目""" - import shutil - + project_path = Path(project_name) package_name = _runtime_package_name_or_exit(project_path.name) - source_dir_name = from_agent_dir.name - if project_path.exists(): print_error(f"目录 '{project_name}' 已存在") raise SystemExit(1) - + print_title("包装 Agent 目录") print_kv("创建项目", project_name) print_kv("框架", framework) print_kv("包装目录", str(from_agent_dir)) print_kv("入口文件", entry_file.name) print_kv("Agent 变量", agent_var) - + # 创建项目目录 project_path.mkdir(parents=True) - + # 复制整个源目录到项目中(作为 package) dest_package_path = project_path / package_name ignore_names = { @@ -1272,42 +1340,46 @@ def _ignore_copytree(_dir: str, names: list[str]): return ignored shutil.copytree(from_agent_dir, dest_package_path, ignore=_ignore_copytree) - + print_info(f"已复制 {sum(1 for _ in dest_package_path.rglob('*.py'))} 个 Python 文件") - + # 递归修复 .env 路径 fixed_count = _fix_dotenv_paths_recursive(dest_package_path, depth=2) if fixed_count > 0: print_info(f"已自动修复 {fixed_count} 个文件的 .env 加载路径") - + # 修复嵌套目录导入路径 # 查找源目录中的子目录(作为 Python 包) - subdirs = [d.name for d in from_agent_dir.iterdir() - if d.is_dir() and not d.name.startswith('.') and not d.name.startswith('_') - and (d / '__init__.py').exists()] - + subdirs = [ + d.name + for d in from_agent_dir.iterdir() + if d.is_dir() + and not d.name.startswith(".") + and not d.name.startswith("_") + and (d / "__init__.py").exists() + ] + if subdirs: # 修复入口文件中的导入 dest_entry_file = dest_package_path / entry_file.relative_to(from_agent_dir) if _fix_nested_imports(dest_entry_file, subdirs): print_info(f"已修复嵌套目录导入: {', '.join(subdirs)}") - - + # 检测全局配置 from ksadk.configs.global_config import ( - global_config_exists, get_env_from_global_config, + global_config_exists, ) - + global_env = {} if global_config_exists(): global_env = get_env_from_global_config() if global_env: - print_info("检测到全局配置,已自动填充凭证") - + print_info("检测到全局配置;项目 .env 仅保留非敏感默认值,凭证不会被复制") + # 生成 .env (project_path / ".env").write_text(_generate_env_content(global_env), encoding="utf-8-sig") - + # 生成 agentengine.yaml # entry_point 相对于项目根目录 entry_relative = entry_file.relative_to(from_agent_dir) @@ -1350,72 +1422,79 @@ def _ignore_copytree(_dir: str, names: list[str]): export_agent_var = "root_agent" print_warn("LangGraph state 检测: ambiguous adapter generated, review required") - (project_path / "agentengine.yaml").write_text(f"""# AgentEngine 项目配置 (Wrapped Directory) + (project_path / "agentengine.yaml").write_text( + f"""# AgentEngine 项目配置 (Wrapped Directory) name: {package_name} version: "1.0.0" framework: {framework} entry_point: {package_name}/{entry_relative} agent_variable: {export_agent_var} -""", encoding="utf-8-sig") - +""", + encoding="utf-8-sig", + ) + # 确保 __init__.py 正确导出 root_agent init_file = dest_package_path / "__init__.py" entry_module = ".".join(entry_relative.with_suffix("").parts) # e.g. src.agentengine_adapter expected_export_line = f"from .{entry_module} import {export_agent_var} as root_agent" - + # 检查现有 __init__.py 是否已导出 root_agent init_has_export = False if init_file.exists(): - init_content = init_file.read_text(encoding='utf-8') + init_content = init_file.read_text(encoding="utf-8") if expected_export_line in init_content: init_has_export = True - + if not init_has_export: # 追加或创建导出语句 - export_code = f''' + export_code = f""" # AgentEngine 导出 (自动添加) {expected_export_line} __all__ = ["root_agent"] -''' +""" if init_file.exists(): # 追加到现有文件 - with open(init_file, 'a', encoding='utf-8') as f: + with open(init_file, "a", encoding="utf-8") as f: f.write(export_code) print_info("已修复 __init__.py 导出") else: - init_file.write_text(f'''""" + init_file.write_text( + f'''""" {project_name} - Wrapped Agent """ {expected_export_line} __all__ = ["root_agent"] -''', encoding="utf-8-sig") - +''', + encoding="utf-8-sig", + ) + # 处理 requirements.txt source_requirements = from_agent_dir / "requirements.txt" dest_requirements = project_path / "requirements.txt" - + if source_requirements.exists(): # 如果源目录有 requirements.txt,复制并补充必要的依赖 import shutil as shutil_req + shutil_req.copy(source_requirements, dest_requirements) print_info("已复制 requirements.txt") - + # 检查是否缺少必要依赖,追加 - existing_reqs = dest_requirements.read_text(encoding='utf-8').lower() + existing_reqs = dest_requirements.read_text(encoding="utf-8").lower() missing = [] - if framework == "langgraph" and 'langgraph' not in existing_reqs: + if framework == "langgraph" and "langgraph" not in existing_reqs: missing.append("langgraph") if framework == "deepagents": - if 'deepagents' not in existing_reqs: + if "deepagents" not in existing_reqs: missing.append("deepagents") - if 'langgraph' not in existing_reqs: + if "langgraph" not in existing_reqs: missing.append("langgraph") - if 'python-dotenv' not in existing_reqs and 'dotenv' not in existing_reqs: + if "python-dotenv" not in existing_reqs and "dotenv" not in existing_reqs: missing.append("python-dotenv") - + if missing: - with open(dest_requirements, 'a', encoding='utf-8') as f: + with open(dest_requirements, "a", encoding="utf-8") as f: f.write("\n# Added by agentengine\n") for pkg in missing: f.write(f"{pkg}\n") @@ -1425,9 +1504,10 @@ def _ignore_copytree(_dir: str, names: list[str]): reqs = _generate_requirements_from_imports(dest_package_path, framework) dest_requirements.write_text(reqs, encoding="utf-8") print_info(f"已自动生成 requirements.txt ({reqs.count(chr(10))} 个依赖)") - + # 生成 README.md - (project_path / "README.md").write_text(f"""# {project_name} + (project_path / "README.md").write_text( + f"""# {project_name} Wrapped Agent project (from `{from_agent_dir.name}/`). @@ -1437,14 +1517,16 @@ def _ignore_copytree(_dir: str, names: list[str]): cd {project_name} agentengine run -i . ``` -""", encoding="utf-8-sig") - +""", + encoding="utf-8-sig", + ) + # === 运行时验证 === print_rule("验证 Agent 加载") import subprocess import sys - - verify_code = f''' + + verify_code = f""" import sys sys.path.insert(0, ".") try: @@ -1453,16 +1535,16 @@ def _ignore_copytree(_dir: str, names: list[str]): except Exception as e: print("ERROR:" + str(e)) sys.exit(1) -''' - +""" + result = subprocess.run( [sys.executable, "-c", verify_code], cwd=str(project_path), capture_output=True, text=True, - timeout=30 + timeout=30, ) - + if result.returncode == 0: output = result.stdout.strip() if output.startswith("TYPE:"): @@ -1474,47 +1556,16 @@ def _ignore_copytree(_dir: str, names: list[str]): error_msg = result.stderr or result.stdout print_warn(f"Agent 加载警告: {error_msg[:200]}") print_info("提示: 请检查依赖是否已安装,或代码是否有错误") - + print_success("包装完成") print_rule("快速开始") _print_quick_start_commands(project_name, ["agentengine run -i ."]) -def _write_hermes_project_template(project_path: Path, project_name: str, package_name: str) -> None: - """生成 Hermes container-first 项目骨架。""" - repo_template_root = Path(__file__).resolve().parents[2] / "deploy" / "hermes" - if repo_template_root.exists(): - replacements = { - "{{PROJECT_NAME}}": project_name, - "{{PACKAGE_NAME}}": package_name, - } - for source in repo_template_root.rglob("*"): - relative = source.relative_to(repo_template_root) - if source.is_dir(): - (project_path / relative).mkdir(parents=True, exist_ok=True) - continue - destination_relative = relative - if source.name.endswith(".template"): - destination_relative = relative.with_name(source.name[:-9]) - content = source.read_text(encoding="utf-8") - for key, value in replacements.items(): - content = content.replace(key, value) - destination = project_path / destination_relative - destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(content, encoding="utf-8-sig") - continue - destination = project_path / destination_relative - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) - entrypoint = project_path / "entrypoint.sh" - if entrypoint.exists(): - entrypoint.chmod(0o755) - return - - runtime_dir = project_path / "runtime" - runtime_dir.mkdir(parents=True, exist_ok=True) - - (project_path / "agentengine.yaml").write_text(f"""# AgentEngine Hermes 项目配置 +def _write_hermes_project_config(project_path: Path, package_name: str) -> None: + """Write the deployable Hermes config; the runtime image is platform-owned.""" + (project_path / "agentengine.yaml").write_text( + f"""# AgentEngine Hermes project configuration name: {package_name} version: "1.0.0" @@ -1532,353 +1583,90 @@ def _write_hermes_project_template(project_path: Path, project_name: str, packag min_replicas: 1 max_replicas: 1 concurrency: 1000 -""", encoding="utf-8-sig") - - (project_path / "Dockerfile").write_text("""FROM python:3.12-slim - -ENV PYTHONUNBUFFERED=1 \\ - PYTHONDONTWRITEBYTECODE=1 \\ - PIP_NO_CACHE_DIR=1 \\ - PORT=8080 \\ - API_SERVER_HOST=127.0.0.1 \\ - API_SERVER_PORT=8642 \\ - HERMES_DASHBOARD_HOST=127.0.0.1 \\ - HERMES_DASHBOARD_PORT=9119 - -WORKDIR /app - -RUN apt-get update \\ - && apt-get install -y --no-install-recommends bash curl tini \\ - && rm -rf /var/lib/apt/lists/* - -RUN pip install --no-cache-dir \\ - "fastapi>=0.100" \\ - "uvicorn[standard]>=0.23" \\ - "httpx>=0.24" \\ - "pyyaml>=6.0" \\ - "python-dotenv>=1.0" \\ - "hermes-agent==0.9.0" - -COPY runtime ./runtime -COPY entrypoint.sh ./entrypoint.sh -RUN chmod +x /app/entrypoint.sh - -EXPOSE 8080 - -ENTRYPOINT ["tini", "--"] -CMD ["/app/entrypoint.sh"] -""", encoding="utf-8") - - (project_path / "entrypoint.sh").write_text("""#!/usr/bin/env bash -set -euo pipefail - -export HOME="${HOME:-/home/agent}" -export PORT="${PORT:-8080}" -export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}" -export API_SERVER_PORT="${API_SERVER_PORT:-8642}" -export HERMES_DASHBOARD_HOST="${HERMES_DASHBOARD_HOST:-127.0.0.1}" -export HERMES_DASHBOARD_PORT="${HERMES_DASHBOARD_PORT:-9119}" -export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}" - -mkdir -p "${HOME}/.hermes" - -cat > "${HOME}/.hermes/.env" < "${HOME}/.hermes/config.yaml" </dev/null || true -} -trap cleanup EXIT - -exec uvicorn runtime.app:app --host 0.0.0.0 --port "${PORT}" -""", encoding="utf-8") - - (runtime_dir / "__init__.py").write_text("", encoding="utf-8") - (runtime_dir / "app.py").write_text("""from __future__ import annotations - -import asyncio -import contextlib -import json -import os -import pty -import select -import signal -import termios -from typing import Iterable - -import httpx -from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import Response -from starlette.websockets import WebSocketState - - -TERMINAL_SUBPROTOCOL = "ks-terminal.v1" -SHELL_METACHARS = set("|&;<>()$`\\\\\\n\\r") -SINGLE_READONLY = {"status", "doctor", "version", "insights"} -NESTED_READONLY = { - "sessions": {"list": (2, 2), "show": (3, 3), "export": (3, 3)}, - "config": {"show": (2, 2), "check": (2, 2), "path": (2, 2), "env-path": (2, 2)}, - "skills": {"list": (2, 2), "audit": (2, 2), "check": (2, 2)}, - "tools": {"list": (2, 2)}, - "cron": {"list": (2, 2), "status": (2, 2)}, - "gateway": {"status": (2, 2)}, -} - -app = FastAPI() - - -def _api_base() -> str: - return f"http://{os.getenv('API_SERVER_HOST', '127.0.0.1')}:{os.getenv('API_SERVER_PORT', '8642')}" - - -def _dashboard_base() -> str: - return f"http://{os.getenv('HERMES_DASHBOARD_HOST', '127.0.0.1')}:{os.getenv('HERMES_DASHBOARD_PORT', '9119')}" - - -def _validate_exec_argv(argv: Iterable[str]) -> list[str]: - normalized = [str(item).strip() for item in argv] - if not normalized: - raise ValueError("missing argv") - for item in normalized: - if not item or item.startswith("-") or any(char in SHELL_METACHARS for char in item): - raise ValueError(f"unsafe argv: {item}") - if normalized[0] in SINGLE_READONLY: - if len(normalized) != 1: - raise ValueError("unsupported argv") - return normalized - nested = NESTED_READONLY.get(normalized[0]) - if not nested or len(normalized) < 2: - raise ValueError("unsupported argv") - bounds = nested.get(normalized[1]) - if not bounds: - raise ValueError("unsupported argv") - if not (bounds[0] <= len(normalized) <= bounds[1]): - raise ValueError("unsupported argv") - return normalized - - -async def _proxy_http(request: Request, base_url: str, path: str) -> Response: - target = f"{base_url}/{path.lstrip('/')}" - if request.url.query: - target = f"{target}?{request.url.query}" - headers = { - key: value - for key, value in request.headers.items() - if key.lower() not in {"host", "content-length"} - } - async with httpx.AsyncClient(timeout=None) as client: - upstream = await client.request( - request.method, - target, - headers=headers, - content=await request.body(), - ) - response_headers = { - key: value - for key, value in upstream.headers.items() - if key.lower() not in {"content-encoding", "transfer-encoding", "connection"} - } - return Response(upstream.content, status_code=upstream.status_code, headers=response_headers) - - -@app.get("/health") -async def health() -> dict: - return {"ok": True} - - -@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) -async def proxy_api(path: str, request: Request) -> Response: - return await _proxy_http(request, _api_base(), f"v1/{path}") - - -def _set_winsize(fd: int, rows: int, cols: int) -> None: - termios.tcsetwinsize(fd, (int(rows or 24), int(cols or 80))) - - -async def _pty_reader(ws: WebSocket, fd: int) -> None: - loop = asyncio.get_running_loop() - while True: - await loop.run_in_executor(None, lambda: select.select([fd], [], [], None)) - data = os.read(fd, 4096) - if not data: - return - await ws.send_bytes(data) - +""", + encoding="utf-8-sig", + ) -async def _wait_process(pid: int) -> int: - loop = asyncio.get_running_loop() - _, status = await loop.run_in_executor(None, os.waitpid, pid, 0) - if os.WIFEXITED(status): - return os.WEXITSTATUS(status) - if os.WIFSIGNALED(status): - return 128 + os.WTERMSIG(status) - return status +def _write_codex_project_config(project_path: Path, package_name: str) -> None: + """Write the minimal codex runtime project:yaml 驱动,无 package/agent.py。 -@app.websocket("/_ksadk/terminal/ws") -async def terminal_ws(ws: WebSocket) -> None: - if TERMINAL_SUBPROTOCOL not in (ws.headers.get("sec-websocket-protocol") or ""): - await ws.close(code=4400, reason="missing ks-terminal.v1 subprotocol") - return - await ws.accept(subprotocol=TERMINAL_SUBPROTOCOL) - pid = None - fd = None - receive_task = None - reader_task = None - wait_task = None - try: - first = await ws.receive_text() - payload = json.loads(first) - if payload.get("type") != "start": - raise ValueError("first frame must be start") - mode = payload.get("mode") - argv = payload.get("argv") or [] - if mode == "tui": - command = ["hermes", "chat"] - elif mode == "exec": - command = ["hermes", *_validate_exec_argv(argv)] - else: - raise ValueError("unsupported mode") - - pid, fd = pty.fork() - if pid == 0: - os.execvp(command[0], command) - - _set_winsize(fd, int(payload.get("rows") or 24), int(payload.get("cols") or 80)) - await ws.send_text(json.dumps({"type": "ready"})) - reader_task = asyncio.create_task(_pty_reader(ws, fd)) - wait_task = asyncio.create_task(_wait_process(pid)) - receive_task = asyncio.create_task(ws.receive()) - - while True: - done, _pending = await asyncio.wait({wait_task, receive_task}, return_when=asyncio.FIRST_COMPLETED) - if wait_task in done: - if reader_task: - reader_task.cancel() - if receive_task: - receive_task.cancel() - code = wait_task.result() - if ws.client_state == WebSocketState.CONNECTED: - await ws.send_text(json.dumps({"type": "exit", "code": code})) - return + codex 的 agent 逻辑由 prompt 承载(CodexRunner.load_agent 是 no-op),所以只生成 + canonical agentengine.yaml(framework: codex,model+prompt)+ requirements + + README。model/prompt 经 CodexRunner 传入 codex thread(开发者指令)。 + """ + (project_path / "agentengine.yaml").write_text( + f"""# AgentEngine codex runtime 项目配置 +name: {package_name} +version: "1.0.0" - message = receive_task.result() - receive_task = asyncio.create_task(ws.receive()) - if message.get("bytes") is not None: - os.write(fd, message["bytes"]) - continue - text = message.get("text") - if not text: - continue - control = json.loads(text) - if control.get("type") == "resize": - _set_winsize(fd, int(control.get("rows") or 24), int(control.get("cols") or 80)) - elif control.get("type") == "signal": - sig = signal.SIGINT if control.get("signal") == "SIGINT" else signal.SIGTERM - os.kill(pid, sig) - elif control.get("type") == "stdin_eof": - os.close(fd) - fd = None - except WebSocketDisconnect: - return - except Exception as exc: - if ws.client_state == WebSocketState.CONNECTED: - await ws.send_text(json.dumps({"type": "error", "message": str(exc)})) - await ws.close() - finally: - for task in (receive_task, reader_task): - if task and not task.done(): - task.cancel() - if pid is not None and (wait_task is None or not wait_task.done()): - with contextlib.suppress(ProcessLookupError): - os.kill(pid, signal.SIGTERM) - if wait_task and not wait_task.done(): - wait_task.cancel() - if fd is not None: - with contextlib.suppress(OSError): - os.close(fd) - - -@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) -async def proxy_dashboard(path: str, request: Request) -> Response: - return await _proxy_http(request, _dashboard_base(), path) -""", encoding="utf-8") - - (project_path / "README.md").write_text(f"""# {project_name} - -Hermes AgentEngine container-first 项目。 +# 框架:codex(开发态 CodexRunner → 部署态 codex-runtime) +framework: codex +artifact_type: ManagedRuntime + +# 托管运行时版本可选;省略时云端由 Runtime catalog 选择默认值。 +# 本地 `ksadk web` 会使用已安装的 openai-codex 并提示该版本未锁定。 +runtime: + name: codex + +# codex 的模型与开发者指令(CodexRunner 读取并传入 codex thread) +model: glm-5.2 +prompt: | + 你是 codex 编码助手。简洁回答,能跑命令验证就跑(shell 工具),中文回复。 +""", + encoding="utf-8-sig", + ) + (project_path / "requirements.txt").write_text( + "ksadk[codex]\n", + encoding="utf-8", # 无 BOM:pip 解析 BOM 会报 Invalid requirement + ) + (project_path / "README.md").write_text( + f"""# {package_name} — codex runtime agent ## 快速开始 ```bash -cd {project_name} - -# 1. 编辑 .env,填写模型配置 -vim .env - -# 2. 部署平台预置 Hermes runtime 镜像 -agentengine hermes deploy - -# 3. 打开 Hermes 管理 UI -agentengine hermes open - -# 4. 进入 pod 内 Hermes 原生 TUI -agentengine invoke - -# 5. 使用统一 hosted chat -agentengine hermes open --chat - -# 6. 受限只读运维子命令 -agentengine hermes exec -- status - -# 7. Pairing 审批透传 -agentengine hermes pairing -- list +# 1. 编辑 .env 填 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL_NAME +# 2. 本地运行(浏览器对话,codex 自动探测并启用代理) +agentengine web . +# 或 +ksadk web . ``` -## Runtime Contract +## 说明 -- `/` 反代到 Hermes dashboard 管理 UI -- `/chat` 由 AgentEngine hosted UI/router 处理 -- `/v1/*` 反代到 Hermes OpenAI-compatible API server -- `/_ksadk/terminal/ws` 提供 `ks-terminal.v1` 原生 TUI/exec/pairing websocket -""", encoding="utf-8-sig") +- codex 的 agent 逻辑由 `agentengine.yaml` 的 `prompt`(开发者指令)承载,无 agent.py。 +- codex 自动探测模型协议:OpenAI 官方直连,星流 chat 模型自动启用转换代理。 +- `agentengine build .` 只生成 YAML manifest bundle,不打包本机 Codex 二进制。若 + `runtime.version` 未指定,build 需要连接到已配置 Runtime catalog;离线构建请显式锁定版本。 +- 部署时服务端解析 ManagedRuntime 版本和 Linux 镜像 digest。 +""", + encoding="utf-8-sig", + ) -@click.command(context_settings=dict(help_option_names=['-h', '--help'])) -@click.argument('project_name', required=False) -@click.option('--framework', '-f', type=click.Choice(['adk', 'langchain', 'langgraph', 'deepagents', 'openclaw', 'hermes']), - default='langgraph', help='框架类型 (default: langgraph)') -@click.option('--from-agent', 'from_agent_path', type=click.Path(exists=True), - help='包装现有 Agent 文件或目录') +@click.command(context_settings=dict(help_option_names=["-h", "--help"])) +@click.argument("project_name", required=False) +@click.option( + "--framework", + "-f", + type=click.Choice( + ["adk", "langchain", "langgraph", "deepagents", "openclaw", "hermes", "codex"] + ), + default="langgraph", + help="框架类型 (default: langgraph)", +) +@click.option( + "--from-agent", + "from_agent_path", + type=click.Path(exists=True), + help="包装现有 Agent 文件或目录", +) def create(project_name: str, framework: str, from_agent_path: str): """创建新的 Agent 项目 - + PROJECT_NAME: 项目名称 - + 使用 --from-agent 可包装现有代码: agentengine init --from-agent ./my_agent.py # 单文件 agentengine init --from-agent ./my_agent/ # 目录 @@ -1886,158 +1674,151 @@ def create(project_name: str, framework: str, from_agent_path: str): # === 包装模式 === if from_agent_path: from_path = Path(from_agent_path) - + # === 目录模式 === if from_path.is_dir(): print_info(f"扫描目录: {from_path}") - + # 查找入口文件 entry_result = _find_entry_file(from_path) if not entry_result: - print_error("未找到有效的入口文件 (agent.py, main.py, __init__.py 或包含 Agent 定义的文件)") + print_error( + "未找到有效的入口文件 (agent.py, main.py, __init__.py 或包含 Agent 定义的文件)" + ) raise SystemExit(1) - + entry_file, detected_var = entry_result print_info(f"检测到入口: {entry_file.name}") print_info(f"检测到变量: {detected_var}") - + # 检测框架:优先读取已有 agentengine.yaml - detected_framework = _load_framework_from_agentengine_yaml(from_path) or 'unknown' - if detected_framework != 'unknown': + detected_framework = _load_framework_from_agentengine_yaml(from_path) or "unknown" + if detected_framework != "unknown": print_info(f"从 agentengine.yaml 检测到框架: {detected_framework}") else: - entry_content = entry_file.read_text(encoding='utf-8') + entry_content = entry_file.read_text(encoding="utf-8") detected_framework = _detect_framework(entry_content) - + # 如果入口文件未检测到框架,扫描整个目录 - if detected_framework == 'unknown': + if detected_framework == "unknown": for py_file in _iter_python_files(from_path): try: - content = py_file.read_text(encoding='utf-8') + content = py_file.read_text(encoding="utf-8") except Exception: continue detected_framework = _detect_framework(content) - if detected_framework != 'unknown': + if detected_framework != "unknown": break - - if detected_framework == 'unknown': + + if detected_framework == "unknown": print_warn("无法自动检测框架,将使用默认 langgraph") - detected_framework = 'langgraph' + detected_framework = "langgraph" else: print_info(f"检测到框架: {detected_framework}") - + # 如果没有指定项目名,使用目录名 if not project_name: - project_name = from_path.name.replace('_', '-') - - _wrap_agent_directory(from_path, project_name, detected_framework, entry_file, detected_var) + project_name = from_path.name.replace("_", "-") + + _wrap_agent_directory( + from_path, project_name, detected_framework, entry_file, detected_var + ) return - + # === 单文件模式 === else: - content = from_path.read_text(encoding='utf-8') - + content = from_path.read_text(encoding="utf-8") + # 自动检测框架 detected_framework = _detect_framework(content) - if detected_framework == 'unknown': + if detected_framework == "unknown": print_warn("无法自动检测框架,将使用默认 langgraph") - detected_framework = 'langgraph' + detected_framework = "langgraph" else: print_info(f"检测到框架: {detected_framework}") - + # 自动检测 Agent 变量 - detected_var = _detect_agent_variable(content) - if not detected_var: + detected_agent_var = _detect_agent_variable(content) + if not detected_agent_var: print_warn("无法自动检测 Agent 变量,将使用默认 root_agent") - detected_var = 'root_agent' + detected_agent_var = "root_agent" else: - print_info(f"检测到变量: {detected_var}") - + print_info(f"检测到变量: {detected_agent_var}") + # 如果没有指定项目名,使用文件名 if not project_name: - project_name = from_path.stem.replace('_', '-') - - _wrap_agent_file(from_path, project_name, detected_framework, detected_var) + project_name = from_path.stem.replace("_", "-") + + _wrap_agent_file(from_path, project_name, detected_framework, detected_agent_var) return - + # === 正常模板模式 === # 如果没有提供项目名称,进入交互模式 if not project_name: print_title("初始化新项目") - - project_name = questionary.text( - "请输入项目名称:", - style=custom_style - ).ask() - + + project_name = questionary.text("请输入项目名称:", style=custom_style).ask() + if not project_name: print_error("取消创建") raise SystemExit(0) - + framework = questionary.select( "请选择开发框架:", - choices=['langgraph', 'langchain', 'deepagents', 'adk', 'openclaw', 'hermes'], - default='langgraph', - style=custom_style + choices=["langgraph", "langchain", "deepagents", "adk", "openclaw", "hermes", "codex"], + default="langgraph", + style=custom_style, ).ask() - + if not framework: print_error("取消创建") raise SystemExit(0) - + project_path = Path(project_name) - + if project_path.exists(): print_error(f"目录 '{project_name}' 已存在") raise SystemExit(1) - + print_kv("创建项目", project_name) print_kv("框架", framework) - + package_name = _runtime_package_name_or_exit(project_path.name) project_path.mkdir(parents=True) - if framework not in {"openclaw", "hermes"}: + if framework not in {"openclaw", "hermes", "codex"}: (project_path / package_name).mkdir(parents=True) - + # 检测全局配置 from ksadk.configs.global_config import ( - global_config_exists, get_env_from_global_config, + global_config_exists, ) - + global_env = {} if global_config_exists(): global_env = get_env_from_global_config() if global_env: - print_info("检测到全局配置,已自动填充凭证") - + print_info("检测到全局配置;项目 .env 仅保留非敏感默认值,凭证不会被复制") + # .env - 生成配置文件 - # 如果有全局配置,使用全局配置的值;否则使用占位符 - # 如果有全局配置,使用全局配置的值;否则使用空字符串 - api_key = global_env.get("OPENAI_API_KEY", "") + # 仅继承非敏感的 endpoint/model/region 默认值;绝不复制凭证。 base_url = global_env.get("OPENAI_BASE_URL", "") model_name = global_env.get("OPENAI_MODEL_NAME", "") - - ks_ak = global_env.get("KSYUN_ACCESS_KEY", "") - ks_sk = global_env.get("KSYUN_SECRET_KEY", "") + ks_region = global_env.get("KSYUN_REGION", "cn-beijing-6") - ks_account = global_env.get("KSYUN_ACCOUNT_ID", "") - + # 构建 .env 内容 if framework == "openclaw": env_content = f"""# ====================== # OpenClaw 标准部署最小配置 # ====================== -KSYUN_ACCESS_KEY={ks_ak} -KSYUN_SECRET_KEY={ks_sk} +# KSYUN_ACCESS_KEY= +# KSYUN_SECRET_KEY= KSYUN_REGION={ks_region} """ - if ks_account: - env_content += f"KSYUN_ACCOUNT_ID={ks_account}\n" - else: - env_content += "# KSYUN_ACCOUNT_ID=your-account-id\n" + env_content += "# KSYUN_ACCOUNT_ID=your-account-id\n" - env_content += f"\nOPENAI_API_KEY={api_key}\n" + env_content += "\n# OPENAI_API_KEY=\n" if base_url: env_content += f"OPENAI_BASE_URL={base_url}\n" else: @@ -2051,18 +1832,13 @@ def create(project_name: str, framework: str, from_agent_path: str): env_content = f"""# ====================== # Hermes 标准部署最小配置 # ====================== -KSYUN_ACCESS_KEY={ks_ak} -KSYUN_SECRET_KEY={ks_sk} +# KSYUN_ACCESS_KEY= +# KSYUN_SECRET_KEY= KSYUN_REGION={ks_region} """ - if ks_account: - env_content += f"KSYUN_ACCOUNT_ID={ks_account}\n" - else: - env_content += "# KSYUN_ACCOUNT_ID=your-account-id\n" + env_content += "# KSYUN_ACCOUNT_ID=your-account-id\n" - env_content += f""" -OPENAI_API_KEY={api_key} -""" + env_content += "\n# OPENAI_API_KEY=\n" if base_url: env_content += f"OPENAI_BASE_URL={base_url}\n" else: @@ -2083,7 +1859,6 @@ def create(project_name: str, framework: str, from_agent_path: str): PORT=8080 # HERMES_CONTEXT_LENGTH=200000 # HERMES_FALLBACK_MODEL=deepseek-v4-pro -# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:2026.5.29.2-ksadk-v1 """ env_example_content = """# ====================== # Hermes 标准部署最小配置示例 @@ -2106,17 +1881,16 @@ def create(project_name: str, framework: str, from_agent_path: str): PORT=8080 # HERMES_CONTEXT_LENGTH=200000 # HERMES_FALLBACK_MODEL=deepseek-v4-pro -# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:2026.5.29.2-ksadk-v1 """ + elif framework == "codex": + env_content = _generate_codex_env_content(global_env) else: - langfuse_public = global_env.get("LANGFUSE_PUBLIC_KEY", "") - langfuse_secret = global_env.get("LANGFUSE_SECRET_KEY", "") langfuse_url = global_env.get("LANGFUSE_BASE_URL", "") - env_content = f"""# ====================== + env_content = """# ====================== # 模型配置 (必填, 可以从星流平台获取https://ksp.console.ksyun.com/#/apiKey) # ====================== -OPENAI_API_KEY={api_key} +# OPENAI_API_KEY= """ # 可选字段:如果有值则启用,否则注释掉 @@ -2135,15 +1909,8 @@ def create(project_name: str, framework: str, from_agent_path: str): # 可观测性 (可选) # ====================== """ - if langfuse_public: - env_content += f"LANGFUSE_PUBLIC_KEY={langfuse_public}\n" - else: - env_content += "# LANGFUSE_PUBLIC_KEY=pk-xxx\n" - - if langfuse_secret: - env_content += f"LANGFUSE_SECRET_KEY={langfuse_secret}\n" - else: - env_content += "# LANGFUSE_SECRET_KEY=sk-xxx\n" + 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" @@ -2155,23 +1922,13 @@ def create(project_name: str, framework: str, from_agent_path: str): # 金山云配置 (可选,需要部署时必选) # ====================== """ - if ks_ak: - env_content += f"KSYUN_ACCESS_KEY={ks_ak}\n" - else: - env_content += "# KSYUN_ACCESS_KEY=your-api-key-here\n" - - if ks_sk: - env_content += f"KSYUN_SECRET_KEY={ks_sk}\n" - else: - env_content += "# KSYUN_SECRET_KEY=your-api-secret-here\n" + env_content += "# KSYUN_ACCESS_KEY=your-api-key-here\n" + env_content += "# KSYUN_SECRET_KEY=your-api-secret-here\n" env_content += f"KSYUN_REGION={ks_region}\n" - if ks_account: - env_content += f"KSYUN_ACCOUNT_ID={ks_account}\n" - else: - env_content += "# KSYUN_ACCOUNT_ID=your-account-id\n" - + env_content += "# KSYUN_ACCOUNT_ID=your-account-id\n" + # 使用 utf-8-sig 编码 (带 BOM),确保 Windows 程序正确识别为 UTF-8 (project_path / ".env").write_text(env_content, encoding="utf-8-sig") if framework == "hermes": @@ -2185,16 +1942,35 @@ def create(project_name: str, framework: str, from_agent_path: str): print_info("部署前如需覆盖模型/网关参数,可先编辑 .env") return if framework == "hermes": - _write_hermes_project_template(project_path, project_name, package_name) + _write_hermes_project_config(project_path, package_name) print_success("项目创建成功") print_rule("快速开始") print_info("快速开始 (复制并执行):") _print_quick_start_commands(project_name, ["agentengine hermes deploy"]) print_info("部署前如需覆盖模型/运行时参数,可先编辑 .env") return - + if framework == "codex": + _write_codex_project_config(project_path, package_name) + print_success("项目创建成功") + print_rule("快速开始") + print_info("快速开始 (复制并执行):") + _print_quick_start_commands(project_name, ["ksadk web ."]) + print_info("编辑 .env 填星流 OPENAI_API_KEY/BASE/MODEL_NAME;codex 自动探测并启用代理") + # codex 本地环境检测(不阻断):缺 openai-codex SDK 时提醒 + import importlib.util + + if importlib.util.find_spec("openai_codex") is None: + print_warn( + escape( + "缺少 codex runtime SDK:请先 `pip install 'ksadk[codex]'`" + "(内含 codex CLI 二进制)" + ) + ) + return + # agentengine.yaml - Agent 配置 - (project_path / "agentengine.yaml").write_text(f"""# AgentEngine 项目配置 + (project_path / "agentengine.yaml").write_text( + f"""# AgentEngine 项目配置 name: {package_name} version: "1.0.0" @@ -2209,23 +1985,27 @@ def create(project_name: str, framework: str, from_agent_path: str): # deploy: # timeout: 300 # memory: 512 -""", encoding="utf-8-sig") - +""", + encoding="utf-8-sig", + ) + # __init__.py - (project_path / package_name / "__init__.py").write_text(f'''""" + (project_path / package_name / "__init__.py").write_text( + f'''""" {project_name} - KsADK Agent """ from .agent import root_agent __all__ = ["root_agent"] -''', encoding="utf-8-sig") - +''', + encoding="utf-8-sig", + ) + # agent.py template = TEMPLATES[framework]["agent.py"] (project_path / package_name / "agent.py").write_text( - template.format(package_name=package_name), - encoding="utf-8-sig" + template.format(package_name=package_name), encoding="utf-8-sig" ) - + # README.md if framework == "openclaw": readme = f"""# {project_name} @@ -2292,12 +2072,12 @@ def create(project_name: str, framework: str, from_agent_path: str): reqs += "deepagents\nlangchain\nlangchain-openai\nlanggraph\npython-dotenv\n" elif framework == "adk": reqs += "google-adk\npython-dotenv\n" - + (project_path / "requirements.txt").write_text(reqs, encoding="utf-8") - + print_success("项目创建成功") print_rule("快速开始") - + print_info("快速开始 (复制并执行):") _print_quick_start_commands(project_name, ["agentengine config"]) print_info("或直接运行 (环境变量中需包含模型 API Key):") diff --git a/ksadk/cli/cmd_dashboard.py b/ksadk/cli/cmd_dashboard.py index 70cbf677..cb4c0970 100644 --- a/ksadk/cli/cmd_dashboard.py +++ b/ksadk/cli/cmd_dashboard.py @@ -7,7 +7,7 @@ import webbrowser from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Optional, Tuple +from typing import Any, Optional, Tuple, cast import click @@ -34,8 +34,17 @@ render_descriptor_list, render_descriptor_status, ) -from ksadk.cli.ui import print_info, print_kv, print_success, print_warn -from ksadk.cli.ui import is_json_output, is_stdout_tty, output_option as cli_output_option +from ksadk.cli.ui import ( + is_json_output, + is_stdout_tty, + print_info, + print_kv, + print_success, + print_warn, +) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) from ksadk.deployment.state import load_state from ksadk.deployment.ui_config import resolve_ui_config from ksadk.openclaw_gateway import OpenClawGatewayClient @@ -75,9 +84,7 @@ "agentengine dashboard open --agent ", ), open_action_help="打开 Agent Dashboard", - extra_action_help=( - ("share", "管理 Dashboard 分享链接"), - ), + extra_action_help=(("share", "管理 Dashboard 分享链接"),), ) DASHBOARD_SHARE_RESOURCE = ResourceDescriptor( @@ -115,6 +122,7 @@ delete_action_help="撤销 Dashboard 分享链接", ) + class DashboardGroup(click.Group): """支持 `dashboard open` canonical + `dashboard [agent_ref]` 兼容路径。""" @@ -169,8 +177,12 @@ def _abort_dashboard_error( context_settings=CONTEXT_SETTINGS, help=build_resource_group_help(DASHBOARD_RESOURCE), ) -@click.option("--agent", "--agent-id", "agent_option", "-a", hidden=True, help="(兼容) Agent 名称或 ID") -@click.option("--region", "-r", default=None, envvar="KSYUN_REGION", hidden=True, help="(兼容) 区域") +@click.option( + "--agent", "--agent-id", "agent_option", "-a", hidden=True, help="(兼容) Agent 名称或 ID" +) +@click.option( + "--region", "-r", default=None, envvar="KSYUN_REGION", hidden=True, help="(兼容) 区域" +) @click.option("--path", "ui_path", default=None, hidden=True, help="(兼容) 目标 UI 路径") @click.option("--share", is_flag=True, hidden=True, help="(兼容) 创建可分享链接") @click.option( @@ -272,7 +284,11 @@ def dashboard_open( ) -@dashboard.group("share", context_settings=CONTEXT_SETTINGS, help=build_resource_group_help(DASHBOARD_SHARE_RESOURCE)) +@dashboard.group( + "share", + context_settings=CONTEXT_SETTINGS, + help=build_resource_group_help(DASHBOARD_SHARE_RESOURCE), +) def dashboard_share(): pass @@ -281,7 +297,13 @@ def dashboard_share(): @click.argument("agent_ref", required=False) @click.option("--agent", "--agent-id", "agent_option", "-a", help="Agent 名称或 ID") @click.option("--region", "-r", default=None, envvar="KSYUN_REGION", help="区域") -@click.option("--type", "link_type", type=click.Choice(["private", "share"]), default=None, help="链接类型过滤") +@click.option( + "--type", + "link_type", + type=click.Choice(["private", "share"]), + default=None, + help="链接类型过滤", +) @click.option("--status", type=click.Choice(["active", "revoked"]), default=None, help="状态过滤") @pagination_options(default_page=1, default_size=20) @cli_output_option() @@ -309,7 +331,9 @@ def dashboard_share_list( cwd = Path(".").resolve() state = load_state(cwd) - effective_region = _resolve_effective_region(region, state, region_source=_region_parameter_source(ctx, "region")) + effective_region = _resolve_effective_region( + region, state, region_source=_region_parameter_source(ctx, "region") + ) primary_ref, fallback_ref = _resolve_references(explicit_ref, cwd) if not primary_ref: _abort_dashboard_error( @@ -319,16 +343,23 @@ def dashboard_share_list( ), argv=["dashboard", "share", "list"], ) + return try: - detail, _, _ = asyncio.run(_resolve_agent_detail(effective_region, primary_ref, fallback_ref)) + detail, _, _ = asyncio.run( + _resolve_agent_detail(effective_region, primary_ref, fallback_ref) + ) except Exception as e: - _abort_dashboard_error(e, context="获取 Agent 信息失败", argv=["dashboard", "share", "list"]) + _abort_dashboard_error( + e, context="获取 Agent 信息失败", argv=["dashboard", "share", "list"] + ) agent_id = (detail.get("agent_id") or "").strip() agent_name = (detail.get("name") or "").strip() if not agent_id and not agent_name: _abort_dashboard_error( - resolution_error("无法解析 Agent 标识", hints=list(DASHBOARD_SHARE_RESOURCE.resolution_commands)), + resolution_error( + "无法解析 Agent 标识", hints=list(DASHBOARD_SHARE_RESOURCE.resolution_commands) + ), argv=["dashboard", "share", "list"], ) @@ -345,7 +376,9 @@ def dashboard_share_list( ) ) except Exception as e: - _abort_dashboard_error(e, context="查询 Dashboard 链接失败", argv=["dashboard", "share", "list"]) + _abort_dashboard_error( + e, context="查询 Dashboard 链接失败", argv=["dashboard", "share", "list"] + ) links = result.get("links") or [] total = int(result.get("total") or len(links)) render_descriptor_list( @@ -444,7 +477,9 @@ def _open_dashboard( return try: - explicit_ref = merge_agent_inputs(agent_option=agent_option, positional_agent=positional_agent) + explicit_ref = merge_agent_inputs( + agent_option=agent_option, positional_agent=positional_agent + ) except ValueError as e: _abort_dashboard_error(usage_error(str(e)), argv=["dashboard", "open"]) @@ -460,12 +495,15 @@ def _open_dashboard( ), argv=["dashboard", "open"], ) + return if primary_ref.source != "cli": print_info(f"未显式指定 Agent,使用 {primary_ref.source_text}: {primary_ref.value}") try: - detail, used_ref, state_stale = asyncio.run(_resolve_agent_detail(effective_region, primary_ref, fallback_ref)) + detail, used_ref, state_stale = asyncio.run( + _resolve_agent_detail(effective_region, primary_ref, fallback_ref) + ) except Exception as e: _abort_dashboard_error(e, context="获取 Agent 信息失败", argv=["dashboard", "open"]) return @@ -518,25 +556,29 @@ def _open_dashboard( ) ) except Exception as e: - _abort_dashboard_error(e, context="创建 OpenClaw gateway 链接失败", argv=["dashboard", "open"]) + _abort_dashboard_error( + e, context="创建 OpenClaw gateway 链接失败", argv=["dashboard", "open"] + ) return _render_dashboard_open_result(detail=detail, link_data=link_data, no_open=no_open) return link_type = "share" if share else "private" - validated_expires = _normalize_expires_seconds(link_type=link_type, expires_seconds=expires_seconds) + validated_expires = _normalize_expires_seconds( + link_type=link_type, expires_seconds=expires_seconds + ) try: link_data = asyncio.run( - _create_dashboard_access_link( - region=effective_region, - agent_id=(detail.get("agent_id") or "").strip() or None, - agent_name=(detail.get("name") or "").strip() or None, - link_type=link_type, - path=link_path, - expires_seconds=validated_expires, - force_new=force_new, - ) + _create_dashboard_access_link( + region=effective_region, + agent_id=(detail.get("agent_id") or "").strip() or None, + agent_name=(detail.get("name") or "").strip() or None, + link_type=link_type, + path=link_path, + expires_seconds=validated_expires, + force_new=force_new, ) + ) except Exception as e: _abort_dashboard_error(e, context="创建 Dashboard 链接失败", argv=["dashboard", "open"]) return @@ -546,7 +588,9 @@ def _open_dashboard( remote_error("CreateDashboardAccessLink 返回为空"), argv=["dashboard", "open"], ) - _render_dashboard_open_result(detail=detail, link_data=link_data, no_open=no_open, default_link_type=link_type) + _render_dashboard_open_result( + detail=detail, link_data=link_data, no_open=no_open, default_link_type=link_type + ) def _render_dashboard_open_result( @@ -557,7 +601,9 @@ def _render_dashboard_open_result( default_link_type: str = "private", ): open_url = (link_data.get("access_url") or "").strip() - actual_link_type = str(link_data.get("link_type") or default_link_type or "").strip() or default_link_type + actual_link_type = ( + str(link_data.get("link_type") or default_link_type or "").strip() or default_link_type + ) render_descriptor_status( DASHBOARD_SHARE_RESOURCE, @@ -566,13 +612,19 @@ def _render_dashboard_open_result( fields=[ ("ID", str(link_data.get("link_id") or "-"), "#58a6ff"), ("类型", actual_link_type, None), - ("过期时间", _format_dashboard_time(link_data.get("expires_at"), never_text="server-default"), None), + ( + "过期时间", + _format_dashboard_time(link_data.get("expires_at"), never_text="server-default"), + None, + ), ], action="open", item={ "link_id": str(link_data.get("link_id") or "-"), "type": actual_link_type, - "expires_at": _format_dashboard_time(link_data.get("expires_at"), never_text="server-default"), + "expires_at": _format_dashboard_time( + link_data.get("expires_at"), never_text="server-default" + ), "url": open_url, "agent_id": str(detail.get("agent_id") or ""), "agent_name": str(detail.get("name") or ""), @@ -596,11 +648,17 @@ def _validate_ui_path_option(ui_path: Optional[str]) -> None: ) -def _resolve_effective_region(region: Optional[str], state: Optional[dict], *, region_source: str) -> str: +def _resolve_effective_region( + region: Optional[str], state: Optional[dict], *, region_source: str +) -> str: explicit_region = str(region or "").strip() if explicit_region and region_source == "commandline": return explicit_region - if explicit_region and region_source == "environment" and not _is_global_config_injected_region(): + if ( + explicit_region + and region_source == "environment" + and not _is_global_config_injected_region() + ): return explicit_region state_region = str((state or {}).get("region") or "").strip() return state_region or explicit_region or DEFAULT_REGION @@ -643,7 +701,8 @@ def _resolve_openclaw_link_path( return normalized_path state_data = state if isinstance(state, dict) else {} - nested = state_data.get("ui") if isinstance(state_data.get("ui"), dict) else {} + nested_value = state_data.get("ui") + nested = nested_value if isinstance(nested_value, dict) else {} state_path = state_data.get("ui_path") or nested.get("path") if state_path is None: return None @@ -687,7 +746,9 @@ def _resolve_references( if explicit_ref: return ResolvedAgentRef(value=explicit_ref, source="cli"), None - hermes_state_ref = resolve_agent_ref(None, cwd=cwd, include_state=True, include_project_config=False) + hermes_state_ref = resolve_agent_ref( + None, cwd=cwd, include_state=True, include_project_config=False + ) openclaw_state_ref = resolve_openclaw_ref( None, cwd=cwd, @@ -735,6 +796,7 @@ async def _resolve_agent_detail( and _is_not_found_error(err) ) if can_fallback: + assert fallback_ref is not None fallback_detail, fallback_err = await _try_get_agent_detail(client, fallback_ref.value) if fallback_detail: return fallback_detail, fallback_ref, True @@ -746,11 +808,16 @@ async def _resolve_agent_detail( raise Exception("Agent not found") -async def _try_get_agent_detail(client: AgentEngineClient, agent_ref: str) -> Tuple[Optional[dict], Optional[Exception]]: +async def _try_get_agent_detail( + client: AgentEngineClient, agent_ref: str +) -> Tuple[Optional[dict], Optional[Exception]]: err: Optional[Exception] = None - for kwargs in ({"agent_id": agent_ref}, {"name": agent_ref}): + for lookup_by_id in (True, False): try: - agent = await client.get_agent(**kwargs) + if lookup_by_id: + agent = await client.get_agent(agent_id=agent_ref) + else: + agent = await client.get_agent(name=agent_ref) if agent: return _flatten_agent_detail(agent), None except Exception as e: @@ -783,7 +850,7 @@ async def _create_dashboard_access_link( kwargs["name"] = agent_name else: raise Exception("missing agent reference") - return await client.create_dashboard_access_link(**kwargs) + return cast(dict[str, Any], await client.create_dashboard_access_link(**kwargs)) def _build_openclaw_gateway_client(region: str, detail: dict) -> OpenClawGatewayClient: @@ -844,12 +911,14 @@ async def _list_dashboard_access_links( kwargs["name"] = agent_name else: raise Exception("missing agent reference") - return await client.list_dashboard_access_links(**kwargs) + return cast(dict[str, Any], await client.list_dashboard_access_links(**kwargs)) -async def _delete_dashboard_access_link(*, region: str, link_id: str, dry_run: bool = False) -> dict: +async def _delete_dashboard_access_link( + *, region: str, link_id: str, dry_run: bool = False +) -> dict: async with AgentEngineClient(region=region, dry_run=dry_run) as client: - return await client.delete_dashboard_access_link(link_id=link_id) + return cast(dict[str, Any], await client.delete_dashboard_access_link(link_id=link_id)) def _flatten_agent_detail(agent: dict) -> dict: @@ -860,8 +929,14 @@ def _flatten_agent_detail(agent: dict) -> dict: return { "agent_id": basic.get("agent_id") or agent.get("agent_id") or "", "name": basic.get("name") or agent.get("name") or "", - "framework": deploy.get("framework") or basic.get("framework") or agent.get("framework") or "", - "endpoint": quick.get("public_endpoint") or quick.get("private_endpoint") or agent.get("endpoint") or "", + "framework": deploy.get("framework") + or basic.get("framework") + or agent.get("framework") + or "", + "endpoint": quick.get("public_endpoint") + or quick.get("private_endpoint") + or agent.get("endpoint") + or "", "langfuse_url": adv.get("observability_url") or agent.get("langfuse_trace_url") or "", } diff --git a/ksadk/cli/cmd_deploy.py b/ksadk/cli/cmd_deploy.py index bc1431ab..79f9af6e 100644 --- a/ksadk/cli/cmd_deploy.py +++ b/ksadk/cli/cmd_deploy.py @@ -7,27 +7,46 @@ - k8s: Kubernetes 集群 """ -import os -import click -import asyncio from pathlib import Path +from typing import TYPE_CHECKING + +import click + from ksadk.api.client import DryRunExit +from ksadk.cli.dry_run import effective_dry_run, run_async_with_dry_run from ksadk.cli.env_options import env_options, resolve_explicit_env_vars -from ksadk.cli.storage import build_storage_config -from ksadk.common.constants import ( - get_ks3_endpoints, - DEFAULT_SERVERLESS_ENDPOINT, +from ksadk.cli.error_utils import ( + cli_error_from_exception, + is_debug_mode_enabled, + remote_error, + usage_error, + validation_error, ) -from ksadk.cli.dry_run import effective_dry_run, run_async_with_dry_run -from ksadk.cli.error_utils import cli_error_from_exception, is_debug_mode_enabled, remote_error, usage_error, validation_error from ksadk.cli.network_options import ( apply_network_cli_overrides, - apply_network_config as _apply_network_config_shared, network_cli_kwargs, network_options, resolve_deploy_target_network, validate_deploy_target_network, ) +from ksadk.cli.network_options import ( + apply_network_config as _apply_network_config_shared, +) +from ksadk.cli.storage import build_storage_config +from ksadk.cli.ui import ( + capture_standard_output, + get_console, + is_json_output, + new_table, + print_info, + print_kv, + print_rule, + print_success, + print_warn, +) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) from ksadk.cli.workflow_common import ( build_workflow_local_plan, clear_build_metadata, @@ -39,22 +58,14 @@ render_workflow_dry_run, render_workflow_result, resolve_artifact_build_plan, +) +from ksadk.cli.workflow_common import ( should_build_artifact as _workflow_should_build_artifact, ) from ksadk.deployment.ui_config import SUPPORTED_UI_PROFILES, extract_ui_state -from ksadk.cli.ui import ( - capture_standard_output, - get_console, - is_json_output, - new_table, - output_option as cli_output_option, - print_error, - print_info, - print_kv, - print_rule, - print_success, - print_warn, -) + +if TYPE_CHECKING: + from ksadk.deployment import DeployTarget console = get_console() @@ -69,19 +80,29 @@ help="部署目标 (default: serverless)", ) @click.option("--name", "-n", help="部署名称") -@click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域 (default: cn-beijing-6)") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--region", + "-r", + default="cn-beijing-6", + envvar="KSYUN_REGION", + help="区域 (default: cn-beijing-6)", +) +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @click.option( "--artifact-type", - type=click.Choice(["Code", "Container"]), - default="Code", - help="Serverless 部署模式 (default: Code)", + type=click.Choice(["ManagedRuntime", "Code", "Container"]), + default=None, + help="Serverless 部署模式;默认从 agentengine.yaml 推断", ) @click.option("--namespace", default="default", help="K8s 命名空间") @click.option("--port", "-p", default=8000, help="服务端口 (default: 8000)") @click.option("--registry", help="镜像仓库地址 (k8s/serverless Container 模式)") @click.option("--ks3-path", help="KS3 代码包路径 (Serverless Code 模式)") -@click.option("--ks3-bucket", help="KS3 bucket 名称 (Serverless Code 模式, 默认: agentengine-{region})") +@click.option( + "--ks3-bucket", help="KS3 bucket 名称 (Serverless Code 模式, 默认: agentengine-{region})" +) @click.option("--image", help="Docker 镜像地址 (Container 模式)") @click.option("--ui-profile", type=click.Choice(SUPPORTED_UI_PROFILES), help="Dashboard UI 类型") @click.option("--ui-path", help="Dashboard UI 路径 (例如 /)") @@ -90,7 +111,10 @@ @click.option( "--storage-mount-path", default=None, - help="PVC 挂载目录(hermes/openclaw 默认按框架推导;adk/langgraph 等其他框架默认不挂盘,需显式指定)", + help=( + "PVC 挂载目录(hermes/openclaw 默认按框架推导;" + "adk/langgraph 等其他框架默认不挂盘,需显式指定)" + ), ) @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") @network_options @@ -100,7 +124,9 @@ ) @click.option("--push", is_flag=True, help="构建后推送镜像") @click.option("--no-cache", is_flag=True, help="强制重新构建,不使用缓存") -@click.option("--repackage", is_flag=True, help="Code 模式复用依赖缓存,但强制重新打包当前代码/runtime") +@click.option( + "--repackage", is_flag=True, help="Code 模式复用依赖缓存,但强制重新打包当前代码/runtime" +) @click.option("--no-version", is_flag=True, help="部署成功后不自动创建版本快照") @click.option("--auto-rollback", is_flag=True, help="部署失败时自动回滚到上一版本") @click.option("--dry-run", is_flag=True, help="只生成配置,打印 curl 请求,不执行部署") @@ -167,6 +193,15 @@ def deploy( # 执行部署 _ = output_mode dry_run_context: dict[str, object] = {} + + def render_deploy_dry_run(exc: DryRunExit) -> None: + plan_value = dry_run_context.get("plan") + render_workflow_dry_run( + action="deploy", + request=dict(exc.payload) if isinstance(exc.payload, dict) else {}, + plan=dict(plan_value) if isinstance(plan_value, dict) else None, + ) + result = run_async_with_dry_run( _deploy_async( agent_dir, @@ -207,22 +242,13 @@ def deploy( dry_run_context=dry_run_context, ), dry_run=dry_run, - on_dry_run=( - lambda exc: render_workflow_dry_run( - action="deploy", - request=dict(exc.payload or {}), - plan=dict(dry_run_context.get("plan") or {}) or None, - ) - ), + on_dry_run=render_deploy_dry_run, ) if result is not None and is_json_output(): render_workflow_result(action="deploy", result=result) - - def _list_providers(): - """列出所有可用的部署 Provider""" from ksadk.deployment import DeploymentManager @@ -291,8 +317,8 @@ async def _deploy_async( repackage: bool = False, ): """异步部署流程""" + from ksadk.deployment import DeploymentManager, DeployTarget from ksadk.detection import FrameworkDetector - from ksadk.deployment import DeploymentManager, DeployTarget, DeployStatus agent_path = Path(agent_dir).resolve() config = _load_config(agent_path) @@ -337,7 +363,8 @@ async def _deploy_async( ) if detection_result.type.value == "openclaw": raise usage_error( - "OpenClaw 项目请使用 `agentengine openclaw deploy`,不要使用通用 `agentengine deploy`。", + "OpenClaw 项目请使用 `agentengine openclaw deploy`," + "不要使用通用 `agentengine deploy`。", hints=[ "OpenClaw 部署会走专用网关与容器配置流程。", "可先执行 `agentengine openclaw --help` 查看专用命令。", @@ -345,6 +372,22 @@ async def _deploy_async( argv=["deploy"], ) + resolved_runtime = None + if effective_artifact_type == "ManagedRuntime": + if detection_result.type.value != "codex": + raise validation_error("ManagedRuntime v1 目前仅支持 framework: codex") + if ks3_path: + raise validation_error( + "ManagedRuntime 是服务端内联 manifest,不能使用 --ks3-path;请移除该参数后重试。" + ) + from ksadk.managed_runtime import resolve_managed_runtime + + resolved_runtime = await resolve_managed_runtime(config, region=region) + print_kv( + "Runtime", + f"{resolved_runtime.name}@{resolved_runtime.version} ({resolved_runtime.source})", + ) + # 2. 确定部署名称 deploy_name = name or config.get("name") or agent_path.name.replace("-", "_").replace(".", "_") print_kv("部署名称", deploy_name) @@ -359,7 +402,9 @@ async def _deploy_async( try: provider = DeploymentManager.get_provider(target) except ValueError as e: - raise usage_error(str(e), hints=["使用 `agentengine deploy --list-providers` 查看可用目标。"]) + raise usage_error( + str(e), hints=["使用 `agentengine deploy --list-providers` 查看可用目标。"] + ) # 5. 构建部署目标配置 deploy_target = DeployTarget( @@ -384,6 +429,8 @@ async def _deploy_async( "no_cache": no_cache, "repackage": repackage, "env_vars": explicit_env_vars, + "runtime_name": resolved_runtime.name if resolved_runtime else "", + "runtime_version": resolved_runtime.version if resolved_runtime else "", }, ) @@ -439,8 +486,10 @@ async def _deploy_async( normalized_artifact_type = (effective_artifact_type or "Code").strip().lower() explicit_artifact_reference = ks3_path if normalized_artifact_type == "code" else image cached_artifact_reference = None - if not artifact_plan.should_clear_metadata: - cached_artifact_reference = load_cached_artifact_reference(agent_path, effective_artifact_type) + if not artifact_plan.should_clear_metadata and normalized_artifact_type != "managedruntime": + cached_artifact_reference = load_cached_artifact_reference( + agent_path, effective_artifact_type + ) resolved_artifact_plan = resolve_artifact_build_plan( plan=artifact_plan, target=target, @@ -473,7 +522,7 @@ async def _deploy_async( if normalized_artifact_type == "container": package_info.image = resolved_artifact_plan.reference package_info.metadata["image"] = resolved_artifact_plan.reference - else: + elif normalized_artifact_type == "code": package_info.metadata["ks3_path"] = resolved_artifact_plan.reference print_kv("构建目录", str(package_info.build_dir)) @@ -555,11 +604,12 @@ async def _deploy_async( print_warn("⚠️ API Key 仅在首次部署时明文显示,请妥善保存!") if result.message: print_kv("信息", result.message) - + # 9. 自动创建版本快照 (仅热更新时,首次部署平台自动创建 v1) is_update = result.message and "已更新" in result.message if result.agent_id and is_update and not no_version and not dry_run: from ksadk.cli.deploy_utils import auto_release_version + with capture_standard_output(): await auto_release_version(result.agent_id, region, deploy_name) @@ -591,7 +641,11 @@ async def _deploy_async( else: # 可能是 DryRun 的 SKIPPED if result.status.name == "SKIPPED": - dry_run_request = result.metadata.get("dry_run_request") if isinstance(result.metadata, dict) else None + dry_run_request = ( + result.metadata.get("dry_run_request") + if isinstance(result.metadata, dict) + else None + ) if dry_run and dry_run_request: raise DryRunExit(result.message or "Dry Run finished.", payload=dry_run_request) print_warn(f"部署状态: {result.status.value}") @@ -629,13 +683,14 @@ async def _deploy_async( ) if result.message: print_info(result.message) - + # 10. 部署失败时自动回滚 (如果启用了 --auto-rollback) if auto_rollback and result.agent_id and result.status.name not in ["SKIPPED"]: from ksadk.cli.deploy_utils import auto_rollback_to_previous + with capture_standard_output(): await auto_rollback_to_previous(result.agent_id, region) - + except DryRunExit: raise except Exception as e: @@ -656,9 +711,6 @@ def _should_build_artifact(*, target: str, artifact_type: str, ks3_path: str, im ) - - - def _load_config(agent_path: Path) -> dict: """加载配置文件""" import yaml @@ -670,7 +722,7 @@ def _load_config(agent_path: Path) -> dict: if config_path.exists(): # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 - with open(config_path, encoding='utf-8-sig') as f: + with open(config_path, encoding="utf-8-sig") as f: return yaml.safe_load(f) or {} return {} @@ -683,7 +735,9 @@ def _resolve_ui_config_inputs( ui_path: str | None, ui_url: str | None, ) -> tuple[str | None, str | None, str | None]: - config_profile, config_path, config_url = extract_ui_state(config if isinstance(config, dict) else None) + config_profile, config_path, config_url = extract_ui_state( + config if isinstance(config, dict) else None + ) return ( ui_profile if ui_profile is not None else config_profile, ui_path if ui_path is not None else config_path, @@ -694,10 +748,15 @@ def _resolve_ui_config_inputs( def _resolve_artifact_type_input(config: dict, cli_artifact_type: str | None) -> str: raw = cli_artifact_type if raw is None and isinstance(config, dict): - deploy_config = config.get("deploy") if isinstance(config.get("deploy"), dict) else {} + deploy_value = config.get("deploy") + deploy_config: dict = dict(deploy_value) if isinstance(deploy_value, dict) else {} raw = config.get("artifact_type") or deploy_config.get("artifact_type") normalized = str(raw or "Code").strip().lower() - return "Container" if normalized == "container" else "Code" + if normalized == "container": + return "Container" + if normalized == "managedruntime": + return "ManagedRuntime" + return "Code" def _apply_network_config(config: dict, deploy_target: "DeployTarget") -> None: diff --git a/ksadk/cli/cmd_destroy.py b/ksadk/cli/cmd_destroy.py index 2bbac609..65106607 100644 --- a/ksadk/cli/cmd_destroy.py +++ b/ksadk/cli/cmd_destroy.py @@ -2,11 +2,13 @@ agentengine delete - 删除 Agent 实例 """ -import click import asyncio from pathlib import Path + +import click + from ksadk.cli.agent_ref import resolve_agent_ref -from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run +from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run from ksadk.cli.error_utils import abort_with_cli_error, remote_error, resolution_error, usage_error from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, @@ -15,7 +17,6 @@ print_compatibility_hint, render_resource_status, ) -from ksadk.deployment import DeploymentManager, DeployTarget from ksadk.cli.ui import ( is_json_output, print_error, @@ -25,6 +26,7 @@ print_title, print_warn, ) +from ksadk.deployment import DeploymentManager, DeployTarget def _destroy_impl( @@ -45,7 +47,8 @@ def _destroy_impl( # 2) 显式指定 agent agentengine delete --agent ar-xxxx --account-id X-Ksc-Account-Id --force # 3) 显式指定区域 - KSYUN_REGION=cn-beijing-6 agentengine delete --agent ar-xxxx --account-id X-Ksc-Account-Id --dry-run + KSYUN_REGION=cn-beijing-6 agentengine delete --agent ar-xxxx \ + --account-id X-Ksc-Account-Id --dry-run # 4) 批量删除 agentengine delete ar-xxxx ar-yyyy --account-id X-Ksc-Account-Id --yes """ @@ -83,7 +86,7 @@ def _destroy_impl( # 构造 Provider 和 Target provider_name = "serverless" # 目前默认 serverless - + try: provider = DeploymentManager.get_provider(provider_name) except ValueError as e: @@ -96,7 +99,7 @@ def _destroy_impl( "account_id": account_id, "dry_run": dry_run, "project_dir": str(Path(".").resolve()), - } + }, ) if not dry_run: @@ -148,7 +151,11 @@ def _destroy_impl( render_resource_status( title="Agent 删除结果", - subtitle="批量删除" if len(resolved_agent_ids) > 1 else (resolved_agent_ids[0] if resolved_agent_ids else "-"), + subtitle=( + "批量删除" + if len(resolved_agent_ids) > 1 + else (resolved_agent_ids[0] if resolved_agent_ids else "-") + ), fields=[ ("目标数量", str(len(resolved_agent_ids)), None), ("已删除", ", ".join(deleted_agents) or "-", None), @@ -201,7 +208,7 @@ def run_delete_command( agent_options=agent_options, assume_yes=assume_yes, region=region, - account_id=account_id, + account_id=account_id or "", dry_run=dry_run, ) @@ -214,11 +221,20 @@ def run_delete_command( canonical_command="agentengine agent delete", ) @click.argument("agent_refs", nargs=-1) -@click.option("--agent", "--agent-id", "agent_options", "-a", multiple=True, help="Agent 名称或 ID,可重复传入") +@click.option( + "--agent", + "--agent-id", + "agent_options", + "-a", + multiple=True, + help="Agent 名称或 ID,可重复传入", +) @click.option("--force", "-f", "assume_yes", is_flag=True, help="跳过确认") @click.option("--yes", "-y", "assume_yes", is_flag=True, help="跳过确认") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @dry_run_option() def destroy( agent_refs: tuple[str, ...], @@ -247,11 +263,20 @@ def destroy( canonical_command="agentengine agent delete", ) @click.argument("agent_refs", nargs=-1) -@click.option("--agent", "--agent-id", "agent_options", "-a", multiple=True, help="Agent 名称或 ID,可重复传入") +@click.option( + "--agent", + "--agent-id", + "agent_options", + "-a", + multiple=True, + help="Agent 名称或 ID,可重复传入", +) @click.option("--yes", "-y", "assume_yes", is_flag=True, help="跳过确认") @click.option("--force", "-f", "assume_yes", is_flag=True, help="跳过确认") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @dry_run_option() def delete( agent_refs: tuple[str, ...], @@ -273,7 +298,9 @@ def delete( ) -def _collect_agent_refs(*, agent_refs: tuple[str, ...], agent_options: tuple[str, ...]) -> list[str]: +def _collect_agent_refs( + *, agent_refs: tuple[str, ...], agent_options: tuple[str, ...] +) -> list[str]: collected = [] for value in [*agent_options, *agent_refs]: normalized = str(value).strip() @@ -292,7 +319,10 @@ def _collect_agent_refs(*, agent_refs: tuple[str, ...], agent_options: tuple[str if not resolved: raise resolution_error( "请指定 Agent(--agent 或位置参数),或在当前目录提供可解析的本地配置", - hints=["自动解析顺序: .agentengine.state -> agentengine.yaml/ksadk.yaml", "请先执行 `agentengine agent list`。"], + hints=[ + "自动解析顺序: .agentengine.state -> agentengine.yaml/ksadk.yaml", + "请先执行 `agentengine agent list`。", + ], ) if resolved.source != "cli": diff --git a/ksadk/cli/cmd_files.py b/ksadk/cli/cmd_files.py index 2090c5a5..82a07056 100644 --- a/ksadk/cli/cmd_files.py +++ b/ksadk/cli/cmd_files.py @@ -54,7 +54,7 @@ def _format_size(size_bytes: object | None) -> str: if size_bytes is None: return "-" try: - value = int(size_bytes) + value = int(str(size_bytes)) except (TypeError, ValueError): return str(size_bytes) if value < 1024: @@ -110,7 +110,9 @@ def _echo_section_title(title: str, *, fg: str = "bright_blue") -> None: click.secho(title, fg=fg, bold=True) -def _normalize_entry_payload(entry: dict, *, root_label: str, workspace_real_root: str | None = None) -> dict: +def _normalize_entry_payload( + entry: dict, *, root_label: str, workspace_real_root: str | None = None +) -> dict: normalized = dict(entry or {}) path = str(normalized.get("path") or "").strip() normalized["display_path"] = _workspace_display_path(path, root_label=root_label) @@ -125,7 +127,10 @@ def _normalize_entry_payload(entry: dict, *, root_label: str, workspace_real_roo def _build_list_payload(payload: dict) -> dict: root_label = _workspace_root_label(payload) - workspace_real_root = str(payload.get("workspace_real_root") or payload.get("workspace_path") or "").strip() or None + workspace_real_root = ( + str(payload.get("workspace_real_root") or payload.get("workspace_path") or "").strip() + or None + ) entries = [ _normalize_entry_payload( entry, @@ -142,7 +147,9 @@ def _build_list_payload(payload: dict) -> dict: "ok": True, "action": "list", "workspace_root": root_label, - "workspace_display_path": _workspace_display_path(payload.get("path", "."), root_label=root_label), + "workspace_display_path": _workspace_display_path( + payload.get("path", "."), root_label=root_label + ), "workspace_real_root": workspace_real_root, "workspace_real_path": _workspace_real_path( payload.get("path", "."), @@ -182,7 +189,9 @@ def _build_upload_payload(payload: dict, *, local_path: Path, remote_path: str) "local_path": str(local_path), "requested_remote_path": remote_path, "remote_path": resolved_remote_path, - "remote_display_path": _workspace_display_path(resolved_remote_path, root_label=root_label), + "remote_display_path": _workspace_display_path( + resolved_remote_path, root_label=root_label + ), "entry": normalized_entry, "summary": { "uploaded": 1, @@ -268,7 +277,9 @@ def _build_sync_payload(payload: dict) -> dict: "ok": True, "action": str(payload.get("direction", "sync")).strip().lower() or "sync", "workspace_root": root_label, - "remote_display_path": _workspace_display_path(payload.get("remote_path", "."), root_label=root_label), + "remote_display_path": _workspace_display_path( + payload.get("remote_path", "."), root_label=root_label + ), "transport_mode": transport_mode or None, "transport_hint": transport_hint, "summary": { @@ -304,20 +315,27 @@ def _emit_payload(payload, output_mode: str | None) -> None: if not entries: click.secho("当前目录为空。", fg="bright_black") return - directories = payload.get("directories") or [entry for entry in entries if entry.get("type") == "directory"] - files = payload.get("files") or [entry for entry in entries if entry.get("type") != "directory"] + directories = payload.get("directories") or [ + entry for entry in entries if entry.get("type") == "directory" + ] + files = payload.get("files") or [ + entry for entry in entries if entry.get("type") != "directory" + ] if directories: _echo_section_title(f"目录({len(directories)})", fg="cyan") for entry in directories: - click.secho(f" {entry.get('display_path') or _workspace_display_path(entry.get('path', ''), root_label=root_label)}", fg="cyan") + display_path = entry.get("display_path") or _workspace_display_path( + entry.get("path", ""), root_label=root_label + ) + click.secho(f" {display_path}", fg="cyan") if files: _echo_section_title(f"文件({len(files)})", fg="green") for entry in files: - click.secho( - f" {entry.get('display_path') or _workspace_display_path(entry.get('path', ''), root_label=root_label)}" - f" {entry.get('size_human') or _format_size(entry.get('size_bytes'))}", - fg="green", + display_path = entry.get("display_path") or _workspace_display_path( + entry.get("path", ""), root_label=root_label ) + size_text = entry.get("size_human") or _format_size(entry.get("size_bytes")) + click.secho(f" {display_path} {size_text}", fg="green") return click.echo(str(payload)) @@ -469,11 +487,13 @@ def _collect_local_files( ignore_dev_artifacts: bool = False, ignore_git_artifacts: bool = True, ) -> list[tuple[str, Path]]: - return _collect_local_files_report( + report = _collect_local_files_report( local_dir, ignore_dev_artifacts=ignore_dev_artifacts, ignore_git_artifacts=ignore_git_artifacts, - )["files"] + ) + files = report.get("files") + return list(files) if isinstance(files, list) else [] def _workspace_client_kwargs( @@ -505,7 +525,12 @@ def _state_prefers_action_proxy(state: dict) -> bool: def _resolve_workspace_region(region: str | None, state: dict) -> str: - return _normalize(region) or _normalize(state.get("region")) or os.getenv("KSYUN_REGION") or DEFAULT_REGION + return ( + _normalize(region) + or _normalize(state.get("region")) + or os.getenv("KSYUN_REGION") + or DEFAULT_REGION + ) def _resolve_workspace_agent_ref(explicit_ref: str | None, cwd: Path) -> str | None: @@ -674,7 +699,7 @@ async def _download_workspace_file( kwargs["endpoint"] = endpoint if api_key: kwargs["api_key"] = api_key - return await client.download_workspace_file(**kwargs) + return bytes(await client.download_workspace_file(**kwargs)) async def _delete_workspace_file( @@ -917,7 +942,9 @@ def files(): @click.option("--api-key", help="Runtime API Key (与 --endpoint 搭配使用)") @click.option("--path", default=".", show_default=True, help="Workspace 目录路径") @click.option("--recursive", is_flag=True, help="递归列出目录") -@click.option("--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)") +@click.option( + "--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)" +) @cli_output_option() def list_files( agent_ref: str | None, @@ -965,7 +992,9 @@ def list_files( help="本地文件路径", ) @click.option("--remote-path", required=True, help="Workspace 目标路径") -@click.option("--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)") +@click.option( + "--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)" +) @cli_output_option() def upload_file( agent_ref: str | None, @@ -1029,7 +1058,9 @@ def upload_file( required=True, help="本地输出文件路径", ) -@click.option("--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)") +@click.option( + "--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)" +) @cli_output_option() def download_file( agent_ref: str | None, @@ -1083,7 +1114,9 @@ def download_file( @click.option("--api-key", help="Runtime API Key (与 --endpoint 搭配使用)") @click.option("--remote-path", required=True, help="Workspace 文件路径") @click.option("--yes", is_flag=True, help="跳过确认") -@click.option("--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)") +@click.option( + "--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)" +) @cli_output_option() def delete_file( agent_ref: str | None, @@ -1105,7 +1138,9 @@ def delete_file( api_key=api_key, region=region, ) - if not yes and not click.confirm(f"删除 workspace 文件 {normalized_remote_path}?", default=False): + if not yes and not click.confirm( + f"删除 workspace 文件 {normalized_remote_path}?", default=False + ): raise click.Abort() payload = asyncio.run( _delete_workspace_file( @@ -1137,7 +1172,9 @@ def delete_file( ) @click.option("--remote-path", default=".", show_default=True, help="Workspace 目标目录") @click.option("--force", is_flag=True, help="覆盖远端已存在的同名文件") -@click.option("--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)") +@click.option( + "--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)" +) @cli_output_option() def push_files( agent_ref: str | None, @@ -1188,7 +1225,9 @@ def push_files( help="本地输出目录", ) @click.option("--force", is_flag=True, help="覆盖本地已存在的同名文件") -@click.option("--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)") +@click.option( + "--region", "-r", default=None, envvar=None, help="区域 (默认优先读取 .agentengine.state)" +) @cli_output_option() def pull_files( agent_ref: str | None, diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index b5a37a8c..2c2f28cf 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -5,7 +5,7 @@ import asyncio import os from pathlib import Path -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, cast import click from click.core import ParameterSource @@ -15,8 +15,8 @@ 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.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 -from ksadk.cli.storage import build_storage_config from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, ResourceActionSet, @@ -32,37 +32,35 @@ render_descriptor_list, render_descriptor_status, ) +from ksadk.cli.storage import build_storage_config from ksadk.cli.ui import ( emit_json, is_json_output, - output_option as cli_output_option, print_info, print_kv, - print_rule, print_success, print_title, print_warn, status_rich_style, ) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) from ksadk.deployment.agent_access import ( get_latest_agent_access, is_agent_not_found_error, normalize_deployment_status, ) from ksadk.deployment.state import clear_state, load_state, save_state -from ksadk.cli.model_catalog import fetch_provider_model_metadata -from ksadk.model_policy import build_runtime_model_policy_env 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 - -DEFAULT_HERMES_IMAGE = "ghcr.io/kingsoftcloud/hermes-agent:2026.5.29.2-ksadk-v1" -DEFAULT_HERMES_CONTEXT_LENGTHS = ( - ("glm-5.1", "200000"), -) +DEFAULT_HERMES_IMAGE = "ghcr.io/kingsoftcloud/hermes-agent:v2026.7.7.2-ksadk-v070" +DEFAULT_HERMES_CONTEXT_LENGTHS = (("glm-5.1", "200000"),) DEFAULT_HERMES_MODEL_NAME = "glm-5.2" DEFAULT_HERMES_PUBLIC_BASE_URL = "https://kspmas.ksyun.com/v1/" DEFAULT_HERMES_RUNTIME_BASE_URL = DEFAULT_HERMES_PUBLIC_BASE_URL @@ -232,14 +230,17 @@ async def _fetch_hermes_bootstrap_config(region: str) -> dict[str, Any] | None: try: async with AgentEngineClient(region=region) as client: - return await client.get_client_bootstrap_config( - product="hermes", - framework="hermes", - region=region, - client_type="cli", - client_version=CLI_VERSION, - locale=_env_value("LANG", "LC_ALL"), - ignore_dry_run=True, + return cast( + dict[str, Any], + await client.get_client_bootstrap_config( + product="hermes", + framework="hermes", + region=region, + client_type="cli", + client_version=CLI_VERSION, + locale=_env_value("LANG", "LC_ALL"), + ignore_dry_run=True, + ), ) except Exception as e: print_warn(f"拉取 Hermes 服务端默认配置失败,回退本地默认镜像: {e}") @@ -279,7 +280,9 @@ def _build_hermes_env_vars( if raw_model_base_url else DEFAULT_HERMES_RUNTIME_BASE_URL ) - resolved_default_model = default_model or _env_value("OPENAI_MODEL_NAME") or DEFAULT_HERMES_MODEL_NAME + resolved_default_model = ( + default_model or _env_value("OPENAI_MODEL_NAME") or DEFAULT_HERMES_MODEL_NAME + ) metadata_context_length = "" if isinstance(model_metadata, dict): metadata_context_length = str(model_metadata.get("context_window_tokens") or "").strip() @@ -307,7 +310,9 @@ def _build_hermes_env_vars( if fallback_model: raw["HERMES_FALLBACK_PROVIDER"] = _env_value("HERMES_FALLBACK_PROVIDER") or "custom" raw["HERMES_FALLBACK_MODEL"] = fallback_model - raw["HERMES_FALLBACK_BASE_URL"] = _env_value("HERMES_FALLBACK_BASE_URL") or resolved_model_base_url + raw["HERMES_FALLBACK_BASE_URL"] = ( + _env_value("HERMES_FALLBACK_BASE_URL") or resolved_model_base_url + ) api_server_key = _env_value("API_SERVER_KEY", "HERMES_API_SERVER_KEY") if api_server_key: raw["API_SERVER_KEY"] = api_server_key @@ -316,7 +321,9 @@ def _build_hermes_env_vars( 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") + 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 { @@ -344,10 +351,19 @@ def _build_hermes_env_vars( raw[key] = value raw = build_runtime_model_policy_env(raw, runtime="hermes") if raw.get("HERMES_FALLBACK_MODEL"): - raw.setdefault("HERMES_FALLBACK_PROVIDER", _env_value("HERMES_FALLBACK_PROVIDER") or "custom") - raw.setdefault("HERMES_FALLBACK_BASE_URL", _env_value("HERMES_FALLBACK_BASE_URL") or resolved_model_base_url) + raw.setdefault( + "HERMES_FALLBACK_PROVIDER", _env_value("HERMES_FALLBACK_PROVIDER") or "custom" + ) + raw.setdefault( + "HERMES_FALLBACK_BASE_URL", + _env_value("HERMES_FALLBACK_BASE_URL") or resolved_model_base_url, + ) return [ - {"Key": key, "Value": str(value), "IsSensitive": any(token in key for token in ("KEY", "TOKEN", "SECRET"))} + { + "Key": key, + "Value": str(value), + "IsSensitive": any(token in key for token in ("KEY", "TOKEN", "SECRET")), + } for key, value in raw.items() if value is not None and str(value).strip() != "" ] @@ -384,9 +400,12 @@ def _diagnostic_field_style(status_value: str) -> str: def _flatten_agent_detail(agent: dict[str, Any]) -> dict[str, Any]: - basic = agent.get("basic") if isinstance(agent.get("basic"), dict) else {} - deployment = agent.get("deployment") if isinstance(agent.get("deployment"), dict) else {} - quick = agent.get("quick_access") if isinstance(agent.get("quick_access"), dict) else {} + basic_value = agent.get("basic") + deployment_value = agent.get("deployment") + quick_value = agent.get("quick_access") + basic = basic_value if isinstance(basic_value, dict) else {} + deployment = deployment_value if isinstance(deployment_value, dict) else {} + quick = quick_value if isinstance(quick_value, dict) else {} return { "agent_id": basic.get("agent_id") or agent.get("agent_id"), "name": basic.get("name") or agent.get("name"), @@ -395,17 +414,25 @@ def _flatten_agent_detail(agent: dict[str, Any]) -> dict[str, Any]: "message": basic.get("message") or "", "replicas": basic.get("replicas"), "ready_replicas": basic.get("ready_replicas"), - "framework": deployment.get("framework") or basic.get("framework") or agent.get("framework"), + "framework": deployment.get("framework") + or basic.get("framework") + or agent.get("framework"), "region": basic.get("region") or agent.get("region"), - "endpoint": quick.get("public_endpoint") or quick.get("private_endpoint") or agent.get("endpoint"), + "endpoint": quick.get("public_endpoint") + or quick.get("private_endpoint") + or agent.get("endpoint"), "api_key": quick.get("api_key") or agent.get("api_key"), "artifact_path": deployment.get("artifact_path") or agent.get("artifact_path"), - "langfuse_url": (agent.get("advanced") or {}).get("observability_url") or agent.get("langfuse_trace_url") or "", + "langfuse_url": (agent.get("advanced") or {}).get("observability_url") + or agent.get("langfuse_trace_url") + or "", } def _resolve_hermes_ref(agent_ref: str | None) -> str: - resolved = resolve_agent_ref(agent_ref, cwd=Path(".").resolve(), include_state=True, include_project_config=True) + resolved = resolve_agent_ref( + agent_ref, cwd=Path(".").resolve(), include_state=True, include_project_config=True + ) if not resolved: raise resolution_error( HERMES_RESOURCE.missing_ref_message or "请指定 Hermes Agent。", @@ -429,13 +456,19 @@ async def _get_hermes_detail_with_client( detail = _flatten_agent_detail(agent) framework = str(detail.get("framework") or "").strip().lower() if framework and framework != "hermes": - raise resolution_error(f"目标 Agent 不是 Hermes: {agent_ref}", hints=["agentengine hermes list"]) + raise resolution_error( + f"目标 Agent 不是 Hermes: {agent_ref}", hints=["agentengine hermes list"] + ) return detail -async def _get_hermes_detail(region: str, agent_ref: str, *, include_api_key: bool = False) -> dict[str, Any]: +async def _get_hermes_detail( + region: str, agent_ref: str, *, include_api_key: bool = False +) -> dict[str, Any]: async with AgentEngineClient(region=region) as client: - return await _get_hermes_detail_with_client(client, agent_ref, include_api_key=include_api_key) + return await _get_hermes_detail_with_client( + client, agent_ref, include_api_key=include_api_key + ) def _resolve_hermes_access( @@ -451,7 +484,9 @@ def _resolve_hermes_access( detail = asyncio.run(_get_hermes_detail(region, resolved, include_api_key=True)) endpoint_value = str(detail.get("endpoint") or "").strip() if not endpoint_value: - raise resolution_error("目标 Hermes Agent 未返回 Endpoint", hints=["agentengine hermes status "]) + raise resolution_error( + "目标 Hermes Agent 未返回 Endpoint", hints=["agentengine hermes status "] + ) return { "endpoint": endpoint_value, "api_key": api_key or detail.get("api_key"), @@ -477,7 +512,9 @@ def _split_terminal_agent_ref_and_argv( raise direct_error -def _render_hermes_dry_run(action: str, request: dict[str, Any], hints: tuple[str, ...] = ()) -> None: +def _render_hermes_dry_run( + action: str, request: dict[str, Any], hints: tuple[str, ...] = () +) -> None: if is_json_output(): emit_json( build_dry_run_envelope( @@ -626,7 +663,9 @@ async def _deploy_hermes( model_api_key=model_api_key, default_model=default_model, ) - resolved_default_model = default_model or _env_value("OPENAI_MODEL_NAME") or DEFAULT_HERMES_MODEL_NAME + resolved_default_model = ( + default_model or _env_value("OPENAI_MODEL_NAME") or DEFAULT_HERMES_MODEL_NAME + ) model_metadata = await fetch_provider_model_metadata( api_base=model_base_url or _env_value("OPENAI_BASE_URL"), api_key=model_api_key or _env_value("OPENAI_API_KEY"), @@ -659,7 +698,9 @@ async def _deploy_hermes( if storage_config: payload["storage"] = storage_config if existing_agent_id and include_storage_on_update and no_storage: - print_warn("更新已有 Hermes 时 `--no-storage` 不会删除服务端既有挂盘配置;默认保留已有配置。") + print_warn( + "更新已有 Hermes 时 `--no-storage` 不会删除服务端既有挂盘配置;默认保留已有配置。" + ) network_payload = build_network_payload( enable_public_access=enable_public_access, enable_vpc_access=enable_vpc_access, @@ -670,7 +711,7 @@ async def _deploy_hermes( region=region, dry_run=dry_run, ) - # create 默认开公网(network_payload 未显式 enable_public_access 时补 True);update 分支用原始 network_payload(None=保留现有配置) + # create 默认开公网;update 使用原始 payload,None 表示保留现有配置。 create_network_payload = dict(network_payload) if network_payload is not None else {} if "enable_public_access" not in create_network_payload: create_network_payload["enable_public_access"] = True @@ -697,9 +738,7 @@ async def _deploy_hermes( raise # 本地 state 缓存的 agent_id 在服务端已不存在(已删除), # 清掉失效 state 后回退为新建,避免 404 卡住用户。 - print_warn( - f"本地状态失效 ({existing_agent_id}),将自动回退为新建: {update_err}" - ) + print_warn(f"本地状态失效 ({existing_agent_id}),将自动回退为新建: {update_err}") cleared = clear_state(project_dir, key=existing_agent_id) if cleared: print_info("已清理失效的 .agentengine.state") @@ -723,10 +762,12 @@ async def _deploy_hermes( attempts=12, interval_seconds=5, include_api_key=True, - detail_fetcher=lambda agent_ref, include_api_key: _get_hermes_detail_with_client( - client, - agent_ref, - include_api_key=include_api_key, + detail_fetcher=lambda agent_ref, include_api_key: ( + _get_hermes_detail_with_client( + client, + agent_ref, + include_api_key=include_api_key, + ) ), suppress_transient_not_found_log=True, ) @@ -753,10 +794,12 @@ async def _deploy_hermes( initial_delay_seconds=2, require_complete_access=True, include_api_key=True, - detail_fetcher=lambda agent_ref, include_api_key: _get_hermes_detail_with_client( - client, - agent_ref, - include_api_key=include_api_key, + detail_fetcher=lambda agent_ref, include_api_key: ( + _get_hermes_detail_with_client( + client, + agent_ref, + include_api_key=include_api_key, + ) ), suppress_transient_not_found_log=True, ) @@ -792,6 +835,7 @@ async def _deploy_hermes( }, ) if is_json_output(): + status_schema = HERMES_RESOURCE.status_schema emit_json( build_result_envelope( resource="hermes", @@ -808,7 +852,7 @@ async def _deploy_hermes( "ui_profile": "hermes", "ui_path": "/", }, - hints=list(HERMES_RESOURCE.status_schema.next_steps), + hints=list(status_schema.next_steps) if status_schema is not None else [], ) ) return @@ -837,7 +881,9 @@ def list_hermes(region: str, page: int, size: int, dry_run: bool, output_mode: s async def _list(): async with AgentEngineClient(region=region, dry_run=dry_run) as client: - resp = await client.list_agents(region=region, framework="hermes", page=page, page_size=size) + resp = await client.list_agents( + region=region, framework="hermes", page=page, page_size=size + ) agents = resp.get("agents", []) or [] rows = [] items = [] @@ -870,7 +916,9 @@ async def _list(): items=items, ) - run_async_with_dry_run(_list(), dry_run=dry_run, dry_run_resource="hermes", dry_run_action="list") + run_async_with_dry_run( + _list(), dry_run=dry_run, dry_run_resource="hermes", dry_run_action="list" + ) @hermes.command("status", context_settings=CONTEXT_SETTINGS) @@ -897,40 +945,50 @@ async def _status(): replicas = detail.get("replicas") ready = detail.get("ready_replicas") if replicas is not None or ready is not None: - replicas_text = f"{ready if ready is not None else '-'}/{replicas if replicas is not None else '-'}" + ready_text = ready if ready is not None else "-" + replicas_total_text = replicas if replicas is not None else "-" + replicas_text = f"{ready_text}/{replicas_total_text}" replica_style = _diagnostic_field_style(status_value) fields.append(("副本", replicas_text, replica_style)) if message: message_style = _diagnostic_field_style(status_value) fields.append(("消息", message, message_style)) - fields.extend([ - ("框架", str(detail.get("framework") or "-"), None), - ("区域", str(detail.get("region") or region), None), - ("Endpoint", str(detail.get("endpoint") or "-"), "#58a6ff"), - ("Langfuse", str(detail.get("langfuse_url") or "-"), "#58a6ff" if detail.get("langfuse_url") else None), - ("镜像", str(detail.get("artifact_path") or "-"), None), - ]) + fields.extend( + [ + ("框架", str(detail.get("framework") or "-"), None), + ("区域", str(detail.get("region") or region), None), + ("Endpoint", str(detail.get("endpoint") or "-"), "#58a6ff"), + ( + "Langfuse", + str(detail.get("langfuse_url") or "-"), + "#58a6ff" if detail.get("langfuse_url") else None, + ), + ("镜像", str(detail.get("artifact_path") or "-"), None), + ] + ) render_descriptor_status( HERMES_RESOURCE, subtitle=str(detail.get("name") or resolved), - fields=fields, - item={ - "id": str(detail.get("agent_id") or "-"), - "name": str(detail.get("name") or resolved), + fields=fields, + item={ + "id": str(detail.get("agent_id") or "-"), + "name": str(detail.get("name") or resolved), "status": status_value, "framework": str(detail.get("framework") or "-"), - "region": str(detail.get("region") or region), - "endpoint": str(detail.get("endpoint") or "-"), - "langfuse_url": str(detail.get("langfuse_url") or ""), - "image": str(detail.get("artifact_path") or "-"), - "message": message, - "phase": str(detail.get("phase") or ""), - "replicas": detail.get("replicas"), - "ready_replicas": detail.get("ready_replicas"), - }, - ) + "region": str(detail.get("region") or region), + "endpoint": str(detail.get("endpoint") or "-"), + "langfuse_url": str(detail.get("langfuse_url") or ""), + "image": str(detail.get("artifact_path") or "-"), + "message": message, + "phase": str(detail.get("phase") or ""), + "replicas": detail.get("replicas"), + "ready_replicas": detail.get("ready_replicas"), + }, + ) - run_async_with_dry_run(_status(), dry_run=dry_run, dry_run_resource="hermes", dry_run_action="status") + run_async_with_dry_run( + _status(), dry_run=dry_run, dry_run_resource="hermes", dry_run_action="status" + ) @hermes.command("open", context_settings=CONTEXT_SETTINGS) @@ -965,11 +1023,8 @@ def open_hermes( """打开 Hermes 管理 UI,或使用 --chat 打开统一聊天页。""" _ = output_mode ctx = click.get_current_context(silent=True) - region_source = ( - ctx.get_parameter_source("region").name.lower() - if ctx is not None and ctx.get_parameter_source("region") is not None - else "" - ) + parameter_source = ctx.get_parameter_source("region") if ctx is not None else None + region_source = parameter_source.name.lower() if parameter_source is not None else "" if manage and chat: raise click.ClickException("--manage 与 --chat 不能同时使用") try: @@ -978,7 +1033,11 @@ def open_hermes( raise click.ClickException(str(e)) from e dry_run = effective_dry_run(dry_run) target_path = ui_path or ("/chat" if chat else "/") - parsed_expires = int(expires_seconds) if expires_seconds is not None and expires_seconds not in {"never", "forever"} else 0 if expires_seconds else None + parsed_expires = ( + int(expires_seconds) + if expires_seconds is not None and expires_seconds not in {"never", "forever"} + else 0 if expires_seconds else None + ) if dry_run: _render_hermes_dry_run( "open", @@ -1052,7 +1111,9 @@ def exec_hermes( hints=("dry-run 未解析远端 Agent,也未建立 websocket。",), ) return - access = _resolve_hermes_access(agent_ref=agent_ref, region=region, endpoint=endpoint, api_key=api_key) + access = _resolve_hermes_access( + agent_ref=agent_ref, region=region, endpoint=endpoint, api_key=api_key + ) exit_code = asyncio.run( run_hermes_terminal_session( endpoint=str(access["endpoint"]), @@ -1116,7 +1177,9 @@ def pairing_hermes( hints=("dry-run 未解析远端 Agent,也未建立 websocket。",), ) return - access = _resolve_hermes_access(agent_ref=agent_ref, region=region, endpoint=endpoint, api_key=api_key) + access = _resolve_hermes_access( + agent_ref=agent_ref, region=region, endpoint=endpoint, api_key=api_key + ) exit_code = asyncio.run( run_hermes_terminal_session( endpoint=str(access["endpoint"]), @@ -1172,7 +1235,9 @@ def connect_hermes( return try: - access = _resolve_hermes_access(agent_ref=agent_ref, region=region, endpoint=endpoint, api_key=api_key) + access = _resolve_hermes_access( + agent_ref=agent_ref, region=region, endpoint=endpoint, api_key=api_key + ) exit_code = asyncio.run( run_hermes_terminal_session( endpoint=str(access["endpoint"]), @@ -1217,7 +1282,9 @@ async def _delete(): raise remote_error(f"以下 Hermes 删除失败: {', '.join(failed)}") return {"targets": list(agent_refs), "deleted": deleted, "failed": failed} - result = run_async_with_dry_run(_delete(), dry_run=dry_run, dry_run_resource="hermes", dry_run_action="delete") + result = run_async_with_dry_run( + _delete(), dry_run=dry_run, dry_run_resource="hermes", dry_run_action="delete" + ) if result is not None: deleted_text = ", ".join(result["deleted"]) or "-" failed_text = ", ".join(result["failed"]) or "-" @@ -1245,7 +1312,13 @@ async def _delete(): @confirm_options() @dry_run_option() @cli_output_option() -def delete(agent_refs: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None): +def delete( + agent_refs: tuple[str, ...], + region: str, + assume_yes: bool, + dry_run: bool, + output_mode: str | None, +): """删除 Hermes Agent。""" _ = output_mode _delete_impl(agent_refs=agent_refs, region=region, assume_yes=assume_yes, dry_run=dry_run) @@ -1257,7 +1330,13 @@ def delete(agent_refs: tuple[str, ...], region: str, assume_yes: bool, dry_run: @confirm_options() @dry_run_option() @cli_output_option() -def destroy(agent_refs: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None): +def destroy( + agent_refs: tuple[str, ...], + region: str, + assume_yes: bool, + dry_run: bool, + output_mode: str | None, +): """删除 Hermes Agent。""" _ = output_mode _delete_impl(agent_refs=agent_refs, region=region, assume_yes=assume_yes, dry_run=dry_run) diff --git a/ksadk/cli/cmd_invoke.py b/ksadk/cli/cmd_invoke.py index d62d9a44..6f7ecbbb 100644 --- a/ksadk/cli/cmd_invoke.py +++ b/ksadk/cli/cmd_invoke.py @@ -4,16 +4,18 @@ 支持 OpenAI 兼容格式调用,支持流式输出 """ -import click import asyncio import io import json import os +import time +import uuid from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, Optional -import time -import uuid + +import click + from ksadk.api import AgentEngineAPIError, AgentEngineClient from ksadk.cli.agent_ref import merge_agent_inputs, resolve_agent_ref, resolve_openclaw_ref from ksadk.cli.cmd_files import ( @@ -24,20 +26,28 @@ _normalize_workspace_dir, _push_workspace_files, ) -from ksadk.cli.resource_common import CONTEXT_SETTINGS, CompatibilityAliasCommand, print_compatibility_hint +from ksadk.cli.resource_common import ( + CONTEXT_SETTINGS, + CompatibilityAliasCommand, + print_compatibility_hint, +) from ksadk.hermes_terminal import run_hermes_terminal_session from ksadk.terminal_client import run_terminal_session from ksadk_runtime_common.workspace_files.constants import DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES +console: Any = None +Markdown: Any = None +Live: Any = None try: - from rich.console import Console - from rich.markdown import Markdown - from rich.live import Live - console = Console() + from rich.console import Console as RichConsole + from rich.live import Live as RichLive + from rich.markdown import Markdown as RichMarkdown + + console = RichConsole() + Markdown = RichMarkdown + Live = RichLive except ImportError: - console = None - Markdown = None - Live = None + pass @click.command( @@ -147,9 +157,9 @@ def _resolve_remote_workspace_seed_path( remote_workspace_path: str | None, ) -> str: if remote_workspace_path: - return _normalize_workspace_dir(remote_workspace_path) + return str(_normalize_workspace_dir(remote_workspace_path)) default_name = local_workspace.resolve().name or local_workspace.name or "workspace" - return _normalize_workspace_dir(default_name) + return str(_normalize_workspace_dir(default_name)) def _summarize_local_workspace_dir( @@ -200,10 +210,12 @@ def _describe_workspace_sync_phase(event: dict[str, Any] | None) -> str: return "同步 workspace" -def _render_workspace_sync_progress_bar(current: object | None, total: object | None, *, width: int = 20) -> str: +def _render_workspace_sync_progress_bar( + current: object | None, total: object | None, *, width: int = 20 +) -> str: try: - current_value = int(current or 0) - total_value = max(int(total or 0), 1) + current_value = int(str(current or 0)) + total_value = max(int(str(total or 0)), 1) except (TypeError, ValueError): current_value = 0 total_value = 1 @@ -214,8 +226,8 @@ def _render_workspace_sync_progress_bar(current: object | None, total: object | def _format_workspace_sync_percent(current: object | None, total: object | None) -> str: try: - current_value = float(current or 0) - total_value = max(float(total or 0), 1.0) + current_value = float(str(current or 0)) + total_value = max(float(str(total or 0)), 1.0) except (TypeError, ValueError): current_value = 0.0 total_value = 1.0 @@ -244,7 +256,8 @@ def _emit(event: dict[str, Any]) -> None: click.secho( "📂 准备同步 workspace: " f"{event.get('total_files', 0)} 个文件," - f"{_format_size(event.get('total_bytes', 0))} -> workspace:/{event.get('remote_path', '.')}", + f"{_format_size(event.get('total_bytes', 0))} -> " + f"workspace:/{event.get('remote_path', '.')}", fg="blue", ) ignored_artifacts = list(event.get("ignored_artifacts") or []) @@ -324,7 +337,7 @@ def _extract_workspace_upload_limit(bootstrap: dict[str, Any] | None) -> int | N return None raw_limit = workspace.get("max_upload_bytes") or workspace.get("MaxUploadBytes") try: - limit = int(raw_limit) + limit = int(str(raw_limit)) except (TypeError, ValueError): return None return limit if limit > 0 else None @@ -336,7 +349,7 @@ async def _lookup_workspace_upload_limit( region: str, ) -> int: if not agent_ref: - return DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES + return int(DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES) try: async with AgentEngineClient(region=region) as client: @@ -345,9 +358,9 @@ async def _lookup_workspace_upload_limit( else: payload = await client.get_agent_ui_bootstrap(name=agent_ref) except Exception: - return DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES + return int(DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES) - return _extract_workspace_upload_limit(payload) or DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES + return int(_extract_workspace_upload_limit(payload) or DEFAULT_WORKSPACE_MAX_UPLOAD_BYTES) async def _sync_local_workspace_for_hermes_invoke( @@ -394,7 +407,8 @@ async def _sync_local_workspace_for_hermes_invoke( if item["size_bytes"] > max_upload_bytes: raise click.ClickException( "本地目录中存在超过远端 workspace 上传上限的文件:" - f"{item['path']}({_format_size(item['size_bytes'])} > {_format_size(max_upload_bytes)})" + f"{item['path']}({_format_size(item['size_bytes'])} > " + f"{_format_size(max_upload_bytes)})" ) if summary["total_bytes"] > max_upload_bytes: @@ -412,7 +426,7 @@ def _record_progress(event: dict[str, Any]) -> None: progress_callback(event) try: - return await _push_workspace_files( + result = await _push_workspace_files( agent_ref=agent_ref, local_dir=local_dir, remote_path=remote_path, @@ -424,11 +438,12 @@ def _record_progress(event: dict[str, Any]) -> None: ignore_dev_artifacts=True, ignore_git_artifacts=".git" in list(summary.get("ignored_artifacts") or []), ) + if not isinstance(result, dict): + raise click.ClickException("同步远端 workspace 返回了无效响应") + return result except AgentEngineAPIError as exc: phase = _describe_workspace_sync_phase(last_progress) - raise click.ClickException( - f"同步远端 workspace 失败({phase}):{exc.message}" - ) from exc + raise click.ClickException(f"同步远端 workspace 失败({phase}):{exc.message}") from exc def run_invoke_command( @@ -479,7 +494,7 @@ def run_invoke_command( state = _load_state() latest_access: dict[str, Any] = {} target_agent: str | None = None - + normalized_transport = (transport or "auto").strip().lower() or "auto" # 确定 Endpoint @@ -518,7 +533,7 @@ def run_invoke_command( endpoint = latest_access["endpoint"] elif not agent_input or _state_matches_target(state, target_agent): endpoint = state.get("endpoint") - + if not endpoint: # 自动获取 endpoint = _get_endpoint(target_agent, region) @@ -548,7 +563,7 @@ def run_invoke_command( latest_access=latest_access, ) - click.secho(f"🤖 连接到 Agent", fg="blue", bold=True) + click.secho("🤖 连接到 Agent", fg="blue", bold=True) click.echo(f" Endpoint: {endpoint}") if _is_openclaw_target(next_state, latest_access): if runtime_api_key: @@ -561,7 +576,7 @@ def run_invoke_command( click.echo(f" Auth: Bearer {_mask_secret(api_key)}") else: click.secho(" ⚠️ 未发现 API Key,尝试匿名调用", fg="yellow") - + if insecure: click.secho(" ⚠️ SSL 证书验证已禁用", fg="yellow") @@ -592,7 +607,6 @@ def run_invoke_command( ) else: is_hermes_target = _is_hermes_target(next_state, latest_access) - is_openclaw_target = _is_openclaw_target(next_state, latest_access) if normalized_transport == "chat" and is_hermes_target: click.secho("❌ Hermes 不再支持 ksadk 通用 chat TUI。", fg="red") click.echo(" 浏览器聊天页请改用: agentengine hermes open --chat") @@ -691,7 +705,9 @@ def run_invoke_command( show_thinking, api_format=api_format_resolved, responses_session_header=( - "x-openclaw-session-key" if _is_openclaw_target(next_state, latest_access) else None + "x-openclaw-session-key" + if _is_openclaw_target(next_state, latest_access) + else None ), no_alt_screen=no_alt_screen, region=region, @@ -699,14 +715,12 @@ def run_invoke_command( ) - - def _invoke_tui( endpoint: str, - api_key: str = None, - session_id: str = None, + api_key: Optional[str] = None, + session_id: Optional[str] = None, insecure: bool = False, - model: str = None, + model: Optional[str] = None, show_thinking: bool = False, api_format: str = "chat_completions", responses_session_header: str | None = None, @@ -740,7 +754,7 @@ def _invoke_tui( if matched is None and runner.available_models: matched = runner.available_models[0] if matched: - runner.model_metadata = matched + setattr(runner, "model_metadata", matched) # 用户未指定 --model 时,用 ListAgentModels 的 current 作为 runner.model, # 让 TUI 启动即显示真实模型名(而非 unknown)。 if not runner.model and matched.get("id"): @@ -751,7 +765,7 @@ def _invoke_tui( _fetch_tui_model_metadata(endpoint=endpoint, api_key=api_key, model=model) ) if model_metadata: - runner.model_metadata = model_metadata + setattr(runner, "model_metadata", model_metadata) run_tui(runner, show_thinking=show_thinking, project_dir=".", no_alt_screen=no_alt_screen) @@ -772,7 +786,8 @@ async def _fetch_tui_model_list( from ksadk.api.client import AgentEngineClient async with AgentEngineClient(region=region) as client: - return await client.list_agent_models(agent_id=agent_id) + payload = await client.list_agent_models(agent_id=agent_id) + return payload if isinstance(payload, dict) else None except Exception: return None @@ -803,7 +818,8 @@ async def _fetch_tui_model_metadata( raw_models = [item.get("_provider_raw_model") or item for item in catalog] matched = find_model_in_catalog(raw_models, model) if matched is not None: - return normalize_model_metadata(matched) + metadata = normalize_model_metadata(matched) + return metadata if isinstance(metadata, dict) else None if model: return None if len(catalog) == 1: @@ -816,10 +832,11 @@ async def _fetch_tui_model_metadata( def _load_state() -> dict: """从 .agentengine.state 加载状态""" import yaml + state_file = Path(".") / ".agentengine.state" if state_file.exists(): try: - with open(state_file, 'r', encoding='utf-8') as f: + with open(state_file, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} except Exception: pass @@ -847,15 +864,13 @@ def _extract_remote_access(detail: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(detail, dict): return {} - basic = detail.get("basic") if isinstance(detail.get("basic"), dict) else {} - quick = detail.get("quick_access") if isinstance(detail.get("quick_access"), dict) else {} - - deployment = detail.get("deployment") if isinstance(detail.get("deployment"), dict) else {} - framework = ( - deployment.get("framework") - or basic.get("framework") - or detail.get("framework") - ) + basic_value = detail.get("basic") + quick_value = detail.get("quick_access") + deployment_value = detail.get("deployment") + basic: Dict[str, Any] = basic_value if isinstance(basic_value, dict) else {} + quick: Dict[str, Any] = quick_value if isinstance(quick_value, dict) else {} + deployment: Dict[str, Any] = deployment_value if isinstance(deployment_value, dict) else {} + framework = deployment.get("framework") or basic.get("framework") or detail.get("framework") return { "agent_id": basic.get("agent_id") or detail.get("agent_id"), @@ -873,9 +888,13 @@ async def _fetch_remote_access(target_agent: str, region: str) -> Dict[str, Any] async with AgentEngineClient(region=region) as client: try: - return _extract_remote_access(await client.get_agent(agent_id=target_agent, include_api_key=True)) + return _extract_remote_access( + await client.get_agent(agent_id=target_agent, include_api_key=True) + ) except Exception: - return _extract_remote_access(await client.get_agent(name=target_agent, include_api_key=True)) + return _extract_remote_access( + await client.get_agent(name=target_agent, include_api_key=True) + ) def _refresh_remote_access( @@ -904,22 +923,20 @@ def _refresh_remote_access( def _is_hermes_target(state: dict, latest_access: dict) -> bool: - framework = str( - latest_access.get("framework") - or state.get("framework") - or state.get("type") - or "" - ).strip().lower() + framework = ( + str(latest_access.get("framework") or state.get("framework") or state.get("type") or "") + .strip() + .lower() + ) return framework == "hermes" def _is_openclaw_target(state: dict, latest_access: dict) -> bool: - framework = str( - latest_access.get("framework") - or state.get("framework") - or state.get("type") - or "" - ).strip().lower() + framework = ( + str(latest_access.get("framework") or state.get("framework") or state.get("type") or "") + .strip() + .lower() + ) return framework == "openclaw" @@ -933,13 +950,17 @@ def _mask_secret(secret: str | None) -> str: def _openclaw_auth_mode(state: dict, latest_access: dict) -> str: - return str( - latest_access.get("openclaw_auth_mode") - or latest_access.get("gateway_auth_mode") - or state.get("openclaw_auth_mode") - or state.get("gateway_auth_mode") - or "" - ).strip().lower() + return ( + str( + latest_access.get("openclaw_auth_mode") + or latest_access.get("gateway_auth_mode") + or state.get("openclaw_auth_mode") + or state.get("gateway_auth_mode") + or "" + ) + .strip() + .lower() + ) def _select_runtime_api_key( @@ -967,11 +988,14 @@ def _select_runtime_api_key( auth_mode = _openclaw_auth_mode(state, latest_access) if auth_mode in {"token", "password"}: raise click.ClickException( - "当前 OpenClaw Gateway 为 token/password 模式,agentengine invoke 需要 OpenClaw Gateway token。\n" + "当前 OpenClaw Gateway 为 token/password 模式," + "agentengine invoke 需要 OpenClaw Gateway token。\n" "请使用: agentengine invoke --gateway-token \n" "或设置: OPENCLAW_GATEWAY_TOKEN= agentengine invoke\n" - "如果部署时传过 OPENCLAW_GATEWAY_TOKEN,请重新运行部署让本地 .agentengine.state 记录该 token。\n" - "注意:这里不是 AgentEngine API Key(ak-*),而是 OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD。" + "如果部署时传过 OPENCLAW_GATEWAY_TOKEN,请重新运行部署让本地 " + ".agentengine.state 记录该 token。\n" + "注意:这里不是 AgentEngine API Key(ak-*),而是 " + "OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD。" ) return api_key @@ -1084,7 +1108,9 @@ async def _probe_responses_route( return available -def _should_use_hermes_native_tui(*, transport: str, local: bool, state: dict, latest_access: dict) -> bool: +def _should_use_hermes_native_tui( + *, transport: str, local: bool, state: dict, latest_access: dict +) -> bool: if transport == "chat": return False if transport == "native": @@ -1094,7 +1120,9 @@ def _should_use_hermes_native_tui(*, transport: str, local: bool, state: dict, l return _is_hermes_target(state, latest_access) -def _should_use_openclaw_native_tui(*, transport: str, local: bool, state: dict, latest_access: dict) -> bool: +def _should_use_openclaw_native_tui( + *, transport: str, local: bool, state: dict, latest_access: dict +) -> bool: if transport == "chat": return False if transport == "native": @@ -1106,14 +1134,16 @@ def _should_use_openclaw_native_tui(*, transport: str, local: bool, state: dict, def _invoke_hermes_terminal_tui( endpoint: str, - api_key: str = None, - session_id: str = None, + api_key: Optional[str] = None, + session_id: Optional[str] = None, insecure: bool = False, cwd: str | None = None, ): click.secho("🖥️ Hermes Native Remote TUI", fg="blue", bold=True) click.echo(" 退出: Ctrl-D 或 Ctrl-C") - click.echo(" 新 Pod 首次启动会加载 Hermes tools/skills,可能需要几十秒到 1 分钟;后续进入会更快。") + click.echo( + " 新 Pod 首次启动会加载 Hermes tools/skills,可能需要几十秒到 1 分钟;后续进入会更快。" + ) try: _warmup_hermes_terminal( endpoint=endpoint, @@ -1172,8 +1202,8 @@ def _warmup_hermes_terminal( def _invoke_openclaw_terminal_tui( endpoint: str, - api_key: str = None, - session_id: str = None, + api_key: Optional[str] = None, + session_id: Optional[str] = None, insecure: bool = False, ): click.secho("🖥️ OpenClaw Native Remote TUI", fg="blue", bold=True) @@ -1206,15 +1236,16 @@ def _get_api_key() -> Optional[str]: def _get_endpoint(agent_ref: str, region: str) -> str: """获取 Agent Endpoint(先按 ID,再按名称)""" - from ksadk.api import AgentEngineClient import asyncio - async def _get(): + from ksadk.api import AgentEngineClient + + async def _get() -> str: async with AgentEngineClient(region=region) as client: # 1) 优先按 ID 查询 try: res = await client.get_agent(agent_id=agent_ref) - endpoint = _extract_remote_access(res).get("endpoint", "") + endpoint = str(_extract_remote_access(res).get("endpoint") or "").strip() if endpoint: return endpoint except Exception: @@ -1222,13 +1253,14 @@ async def _get(): # 2) 回退按名称查询 res = await client.get_agent(name=agent_ref) - endpoint = _extract_remote_access(res).get("endpoint", "") + endpoint = str(_extract_remote_access(res).get("endpoint") or "").strip() if endpoint: return endpoint # endpoint 为空时,尽量提取真实 ID 供默认域名拼接 - basic = res.get("basic", {}) - return basic.get("agent_id") or res.get("agent_id") or "" + basic_value = res.get("basic") + basic = basic_value if isinstance(basic_value, dict) else {} + return str(basic.get("agent_id") or res.get("agent_id") or "") try: resolved = asyncio.run(_get()) @@ -1248,16 +1280,16 @@ async def _get(): async def _invoke_once( endpoint: str, message: str, - api_key: str = None, - session_id: str = None, + api_key: Optional[str] = None, + session_id: Optional[str] = None, stream: bool = True, insecure: bool = False, - model: str = None, + model: Optional[str] = None, api_format: str = "chat_completions", ): """单次调用""" click.echo(f"\n👤 你: {message}") - click.echo(f"🤖 Agent: ", nl=False) + click.echo("🤖 Agent: ", nl=False) try: if stream: @@ -1265,12 +1297,19 @@ async def _invoke_once( if Live and Markdown: # 降低刷新率减少闪烁,vertical_overflow="visible"防止回滚丢失 # 手动控制刷新以减少闪烁 - with Live(Markdown("", justify="left"), console=console, auto_refresh=False, vertical_overflow="visible") as live: - last_refresh_time = 0 + with Live( + Markdown("", justify="left"), + console=console, + auto_refresh=False, + vertical_overflow="visible", + ) as live: + last_refresh_time = 0.0 full_reasoning = "" - async for chunk in _stream_chat(endpoint, message, api_key, session_id, True, insecure, model, api_format): + async for chunk in _stream_chat( + endpoint, message, api_key, session_id, True, insecure, model, api_format + ): content, reasoning = _extract_content(chunk) - + updated = False if reasoning: full_reasoning += reasoning @@ -1278,25 +1317,27 @@ async def _invoke_once( if content: full_response += content updated = True - + if updated: # 构造显示文本 display_text = "" if full_reasoning: - formatted_reasoning = full_reasoning.replace('\n', '\n> ') + formatted_reasoning = full_reasoning.replace("\n", "\n> ") display_text += f"> 🧠 **Thinking:**\n> {formatted_reasoning}\n\n" display_text += full_response - + live.update(Markdown(display_text, justify="left")) - + # 基于时间限流刷新 (每0.2秒一次 = 5 FPS) now = time.time() if now - last_refresh_time > 0.2: live.refresh() last_refresh_time = now - live.refresh() # 确保最后一次刷新 + live.refresh() # 确保最后一次刷新 else: - async for chunk in _stream_chat(endpoint, message, api_key, session_id, True, insecure, model, api_format): + async for chunk in _stream_chat( + endpoint, message, api_key, session_id, True, insecure, model, api_format + ): content, reasoning = _extract_content(chunk) if reasoning: click.secho(reasoning, fg="bright_black", nl=False) @@ -1304,7 +1345,9 @@ async def _invoke_once( print(content, end="", flush=True) click.echo() # 换行 else: - response = await _chat(endpoint, message, api_key, session_id, insecure, model, api_format) + response = await _chat( + endpoint, message, api_key, session_id, insecure, model, api_format + ) content = _extract_response_content(response) if console and Markdown: console.print(Markdown(content)) @@ -1314,16 +1357,15 @@ async def _invoke_once( click.secho(f"\n❌ 调用失败: {e}", fg="red") - async def _chat( endpoint: str, message: str, - api_key: str = None, - session_id: str = None, + api_key: Optional[str] = None, + session_id: Optional[str] = None, insecure: bool = False, - model: str = None, + model: Optional[str] = None, api_format: str = "chat_completions", -) -> dict: +) -> dict[str, Any]: """非流式调用 (OpenAI 兼容格式)""" try: import httpx @@ -1334,10 +1376,16 @@ async def _chat( normalized_api_format = str(api_format or "chat_completions").strip().lower() if normalized_api_format == "responses": url = f"{endpoint.rstrip('/')}/v1/responses" - payload = {"input": [{"role": "user", "content": message}], "stream": False} + payload: dict[str, Any] = { + "input": [{"role": "user", "content": message}], + "stream": False, + } else: url = f"{endpoint.rstrip('/')}/v1/chat/completions" - payload = {"messages": [{"role": "user", "content": message}], "stream": False} + payload = { + "messages": [{"role": "user", "content": message}], + "stream": False, + } if session_id: payload["session_id"] = session_id @@ -1351,31 +1399,37 @@ async def _chat( is_local = "localhost" in url or "127.0.0.1" in url or "0.0.0.0" in url # 构造 httpx client 配置 - client_kwargs = {"timeout": 60, "trust_env": not is_local} + client_kwargs: dict[str, Any] = { + "timeout": 60, + "trust_env": not is_local, + } # 如果指定了 --insecure 参数,跳过 SSL 证书验证(类似 curl -k) if insecure: client_kwargs["verify"] = False # 构造 Headers - headers = {} + headers: dict[str, str] = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" async with httpx.AsyncClient(**client_kwargs) as client: response = await client.post(url, json=payload, headers=headers) response.raise_for_status() - return response.json() + response_payload = response.json() + if not isinstance(response_payload, dict): + raise ValueError("Agent response must be a JSON object") + return response_payload async def _stream_chat( endpoint: str, message: str, - api_key: str = None, - session_id: str = None, + api_key: Optional[str] = None, + session_id: Optional[str] = None, is_once: bool = False, insecure: bool = False, - model: str = None, + model: Optional[str] = None, api_format: str = "chat_completions", ): """流式调用 (SSE)""" @@ -1388,7 +1442,10 @@ async def _stream_chat( normalized_api_format = str(api_format or "chat_completions").strip().lower() if normalized_api_format == "responses": url = f"{endpoint.rstrip('/')}/v1/responses" - payload = {"input": [{"role": "user", "content": message}], "stream": True} + payload: dict[str, Any] = { + "input": [{"role": "user", "content": message}], + "stream": True, + } else: url = f"{endpoint.rstrip('/')}/v1/chat/completions" payload = {"messages": [{"role": "user", "content": message}], "stream": True} @@ -1405,14 +1462,17 @@ async def _stream_chat( is_local = "localhost" in url or "127.0.0.1" in url or "0.0.0.0" in url # 构造 httpx client 配置 - client_kwargs = {"timeout": 60, "trust_env": not is_local} + client_kwargs: dict[str, Any] = { + "timeout": 60, + "trust_env": not is_local, + } # 如果指定了 --insecure 参数,跳过 SSL 证书验证(类似 curl -k) if insecure: client_kwargs["verify"] = False # 构造 Headers - headers = {} + headers: dict[str, str] = {} if api_key: headers["Authorization"] = f"Bearer {api_key}" @@ -1437,6 +1497,8 @@ async def _stream_chat( try: data = json.loads(data_str) + if not isinstance(data, dict): + continue if current_event and isinstance(data, dict): data = {**data, "_event": current_event} # 直接 yield 解析后的 JSON 数据,让 _extract_content 处理 @@ -1497,7 +1559,7 @@ def _extract_response_content(response: dict) -> str: choices = response.get("choices", []) if choices: message = choices[0].get("message", {}) - return message.get("content", "") + return str(message.get("content") or "") except (KeyError, IndexError): pass return str(response) diff --git a/ksadk/cli/cmd_launch.py b/ksadk/cli/cmd_launch.py index 01f564f9..622153be 100644 --- a/ksadk/cli/cmd_launch.py +++ b/ksadk/cli/cmd_launch.py @@ -2,14 +2,26 @@ agentengine launch - 一键完成构建和部署 """ -import click -import asyncio from pathlib import Path +from typing import Any + +import click + from ksadk.api.client import DryRunExit -from ksadk.cli.cmd_deploy import _apply_network_config, _resolve_artifact_type_input, _resolve_ui_config_inputs +from ksadk.cli.cmd_deploy import ( + _apply_network_config, + _resolve_artifact_type_input, + _resolve_ui_config_inputs, +) from ksadk.cli.dry_run import effective_dry_run, run_async_with_dry_run from ksadk.cli.env_options import env_options, resolve_explicit_env_vars -from ksadk.cli.error_utils import cli_error_from_exception, is_debug_mode_enabled, remote_error, usage_error, validation_error +from ksadk.cli.error_utils import ( + cli_error_from_exception, + is_debug_mode_enabled, + remote_error, + usage_error, + validation_error, +) from ksadk.cli.network_options import ( apply_network_cli_overrides, network_cli_kwargs, @@ -18,6 +30,18 @@ validate_deploy_target_network, ) from ksadk.cli.storage import build_storage_config +from ksadk.cli.ui import ( + capture_standard_output, + is_json_output, + print_info, + print_kv, + print_rule, + print_success, + print_warn, +) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) from ksadk.cli.workflow_common import ( build_workflow_local_plan, clear_build_metadata, @@ -31,17 +55,10 @@ resolve_artifact_build_plan, ) from ksadk.deployment.ui_config import SUPPORTED_UI_PROFILES -from ksadk.cli.ui import ( - capture_standard_output, - is_json_output, - output_option as cli_output_option, - print_error, - print_info, - print_kv, - print_rule, - print_success, - print_warn, -) + + +def _dict_or_none(value: object) -> dict[str, Any] | None: + return dict(value) if isinstance(value, dict) and value else None @click.command(context_settings=dict(help_option_names=["-h", "--help"])) @@ -54,8 +71,12 @@ help="部署目标 (default: serverless)", ) @click.option("--name", "-n", help="部署名称") -@click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域 (serverless)") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域 (serverless)" +) +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @click.option("--observability/--no-observability", default=True, help="是否启用可观测性") @click.option("--no-cache", is_flag=True, help="强制重新构建,不使用缓存") @click.option("--port", "-p", default=8000, help="服务端口 (default: 8000)") @@ -71,7 +92,10 @@ @click.option( "--storage-mount-path", default=None, - help="PVC 挂载目录(hermes/openclaw 默认按框架推导;adk/langgraph 等其他框架默认不挂盘,需显式指定)", + help=( + "PVC 挂载目录(hermes/openclaw 默认按框架推导;" + "adk/langgraph 等其他框架默认不挂盘,需显式指定)" + ), ) @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") @network_options @@ -181,7 +205,7 @@ def launch( lambda exc: render_workflow_dry_run( action="launch", request=dict(exc.payload or {}), - plan=dict(dry_run_context.get("plan") or {}) or None, + plan=_dict_or_none(dry_run_context.get("plan")), ) ), ) @@ -223,8 +247,8 @@ async def _launch_async( env_file: str | None = None, dry_run_context: dict[str, object] | None = None, ): - from ksadk.detection import FrameworkDetector from ksadk.deployment import DeploymentManager, DeployTarget + from ksadk.detection import FrameworkDetector agent_path = Path(agent_dir).resolve() config = _load_config(agent_path) @@ -236,7 +260,11 @@ async def _launch_async( ) except ValueError as e: raise validation_error(str(e)) - effective_artifact_type = _resolve_artifact_type_input(config, artifact_type) if target == "serverless" else artifact_type + effective_artifact_type = ( + _resolve_artifact_type_input(config, artifact_type) + if target == "serverless" + else artifact_type + ) print_workflow_header( title="Agent Launch", subtitle=f"target: {target}", @@ -337,7 +365,9 @@ async def _launch_async( explicit_artifact_reference = ks3_path if normalized_artifact_type == "code" else image cached_artifact_reference = None if not artifact_plan.should_clear_metadata: - cached_artifact_reference = load_cached_artifact_reference(agent_path, effective_artifact_type) + cached_artifact_reference = load_cached_artifact_reference( + agent_path, effective_artifact_type + ) resolved_artifact_plan = resolve_artifact_build_plan( plan=artifact_plan, target=target, @@ -381,7 +411,7 @@ async def _launch_async( package_info.metadata["image"] = resolved_artifact_plan.reference else: package_info.metadata["ks3_path"] = resolved_artifact_plan.reference - + print_kv("构建目录", str(package_info.build_dir)) except Exception as e: raise cli_error_from_exception(e, context="打包失败") @@ -412,7 +442,7 @@ async def _launch_async( if ks3: print_kv("KS3 路径", ks3) else: - print_kv("镜像", package_info.image) + print_kv("镜像", package_info.image or "-") except Exception as e: raise cli_error_from_exception(e, context="构建失败") @@ -458,10 +488,11 @@ async def _launch_async( print_warn("请妥善保存此 API Key,它仅在首次部署时显示") if result.message: print_kv("信息", result.message) - + # 9. 自动创建版本快照 (除非指定 --no-version) if result.agent_id and not no_version and not dry_run: from ksadk.cli.deploy_utils import auto_release_version + with capture_standard_output(): await auto_release_version(result.agent_id, region, deploy_name) @@ -494,7 +525,11 @@ async def _launch_async( } else: if result.status.name == "SKIPPED": - dry_run_request = result.metadata.get("dry_run_request") if isinstance(result.metadata, dict) else None + dry_run_request = ( + result.metadata.get("dry_run_request") + if isinstance(result.metadata, dict) + else None + ) if dry_run and dry_run_request: raise DryRunExit(result.message or "Dry Run finished.", payload=dry_run_request) print_warn(f"部署状态: {result.status.value}") @@ -531,10 +566,11 @@ async def _launch_async( ) if result.message: print_info(result.message) - + # 10. 自动回滚 if auto_rollback and result.agent_id and result.status.name not in ["SKIPPED"]: from ksadk.cli.deploy_utils import auto_rollback_to_previous + with capture_standard_output(): await auto_rollback_to_previous(result.agent_id, region) @@ -558,7 +594,7 @@ def _load_config(agent_path: Path) -> dict: if config_path.exists(): # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 - with open(config_path, encoding='utf-8-sig') as f: + with open(config_path, encoding="utf-8-sig") as f: return yaml.safe_load(f) or {} return {} diff --git a/ksadk/cli/cmd_mcp.py b/ksadk/cli/cmd_mcp.py index 4e467a67..50942ff3 100644 --- a/ksadk/cli/cmd_mcp.py +++ b/ksadk/cli/cmd_mcp.py @@ -4,13 +4,23 @@ from dataclasses import asdict, is_dataclass, replace from datetime import datetime from pathlib import Path +from typing import Any import click from ksadk.api.client import DryRunExit +from ksadk.builders.container_builder import ( + registry_kind_label, + resolve_registry_credentials, +) from ksadk.cli.agent_ref import resolve_mcp_ref -from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run -from ksadk.cli.error_utils import abort_with_cli_error, remote_error, resolution_error, usage_error, validation_error +from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run +from ksadk.cli.error_utils import ( + abort_with_cli_error, + remote_error, + resolution_error, + validation_error, +) from ksadk.cli.network_options import build_network_payload, network_cli_kwargs, network_options from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, @@ -22,24 +32,24 @@ confirm_destructive, confirm_options, pagination_options, - print_next_action_hint, + region_option, render_descriptor_list, render_descriptor_status, - region_option, ) from ksadk.cli.ui import ( capture_standard_output, get_console, is_json_output, - output_option as cli_output_option, print_info, print_kv, print_rule, print_success, - print_title, print_warn, status_rich_style, ) +from ksadk.cli.ui import ( + output_option as cli_output_option, +) from ksadk.cli.workflow_common import ( build_workflow_local_plan, clear_build_metadata, @@ -51,10 +61,6 @@ render_workflow_result, resolve_artifact_build_plan, ) -from ksadk.builders.container_builder import ( - registry_kind_label, - resolve_registry_credentials, -) console = get_console() @@ -127,14 +133,14 @@ def mcp(): @click.option("--tag", help="镜像标签 (Container 模式)") @click.option("--registry", help="镜像仓库地址 (Container 模式)") @click.option( - "--region", "-r", + "--region", + "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="构建使用的区域 (Code 模式用于 KS3,Container 模式用于默认镜像仓库推断)", ) @click.option( - "--ks3-bucket", - help="KS3 存储桶名称 (Code 模式,默认: agentengine-{account_id}-{region})" + "--ks3-bucket", help="KS3 存储桶名称 (Code 模式,默认: agentengine-{account_id}-{region})" ) @click.option( "--no-cache", @@ -177,28 +183,18 @@ def build( render_workflow_result(action="build", result=result) -@mcp.command("deploy", context_settings=CONTEXT_SETTINGS) +@mcp.command("deploy", context_settings=CONTEXT_SETTINGS, short_help="部署 MCP Server 到云端") @click.argument("mcp_dir", default=".", type=click.Path(exists=True)) +@click.option("--name", "-n", help="MCP Server 名称 (默认: 目录名)") @click.option( - "--name", "-n", - help="MCP Server 名称 (默认: 目录名)" -) -@click.option( - "--region", "-r", + "--region", + "-r", default="cn-beijing-6", envvar="KSYUN_REGION", - help="部署区域 (default: cn-beijing-6)" -) -@click.option( - "--ks3-bucket", - help="KS3 存储桶名称 (默认: agentengine-{region})" -) -@click.option( - "--enable-auth", - is_flag=True, - default=False, - help="启用 API Key 保护 (可选)" + help="部署区域 (default: cn-beijing-6)", ) +@click.option("--ks3-bucket", help="KS3 存储桶名称 (默认: agentengine-{region})") +@click.option("--enable-auth", is_flag=True, default=False, help="启用 API Key 保护 (可选)") @dry_run_option("仅显示请求内容,不实际部署") @click.option( "--artifact-type", @@ -232,10 +228,10 @@ def deploy( output_mode: str | None, ): """部署 MCP Server 到云端 - + \b MCP_DIR: MCP 项目目录 (默认: 当前目录) - + \b 示例: # 1) 默认部署 (Code 模式) @@ -244,7 +240,7 @@ def deploy( agentengine mcp deploy ./my-mcp --name my-tools --artifact-type Container # 3) 显式指定区域 KSYUN_REGION=cn-beijing-6 agentengine mcp deploy . --dry-run - + \b 部署后的 endpoint 兼容标准 MCP 协议,可以被: - LangGraph/LangChain (via langchain-mcp-adapters) @@ -255,6 +251,15 @@ def deploy( _ = output_mode dry_run = effective_dry_run(dry_run) dry_run_context: dict[str, object] = {} + + def render_mcp_deploy_dry_run(exc: DryRunExit) -> None: + plan_value = dry_run_context.get("plan") + render_workflow_dry_run( + action="deploy", + request=dict(exc.payload) if isinstance(exc.payload, dict) else {}, + plan=dict(plan_value) if isinstance(plan_value, dict) else None, + ) + try: result = run_async_with_dry_run( _deploy_mcp_async( @@ -277,13 +282,7 @@ def deploy( dry_run_context=dry_run_context, ), dry_run=dry_run, - on_dry_run=( - lambda exc: render_workflow_dry_run( - action="deploy", - request=dict(exc.payload or {}), - plan=dict(dry_run_context.get("plan") or {}) or None, - ) - ), + on_dry_run=render_mcp_deploy_dry_run, ) except Exception as e: _abort_mcp_error(e, context="部署失败", argv=["mcp", "deploy"]) @@ -403,9 +402,11 @@ def _print_mcp_build_summary( print_kv("工具", ", ".join(build_result.metadata["tools"])) -def _persist_mcp_build_metadata(mcp_path: Path, build_result, *, artifact_type: str, artifact_reference: str) -> None: +def _persist_mcp_build_metadata( + mcp_path: Path, build_result, *, artifact_type: str, artifact_reference: str +) -> None: metadata_file = mcp_path / ".agentengine" / "build-metadata.json" - if is_dataclass(build_result): + if is_dataclass(build_result) and not isinstance(build_result, type): payload = asdict(build_result) else: payload = { @@ -462,8 +463,11 @@ async def _build_code_artifact( uploader = KS3Uploader(region=upload_region, bucket=ks3_bucket) timestamp = datetime.now().strftime("%Y%m%d%H%M%S") object_key = f"mcps/{mcp_name}/code_{timestamp}.zip" + artifact_path = build_result.artifact_path + if artifact_path is None: + raise validation_error("构建成功但未生成 artifact_path") with capture_standard_output(): - ks3_path = await uploader.upload(build_result.artifact_path, object_key) + ks3_path = await uploader.upload(artifact_path, object_key) if not ks3_path: raise remote_error("KS3 上传失败") artifact_reference = ks3_path @@ -528,7 +532,7 @@ def _build_mcp_request_data( ) -> dict: resources = config.get("resources") or {} scaling = config.get("scaling") or {} - request_data = { + request_data: dict[str, Any] = { "name": mcp_name, "type": "mcp", "artifact_type": artifact_type, @@ -564,15 +568,19 @@ def _build_mcp_request_data( else: kcr_username, kcr_password, registry_kind = resolve_registry_credentials(artifact_reference) if kcr_username and kcr_password: - request_data["image_credential"] = { + image_credential = { "endpoint": artifact_reference.split("/", 1)[0], "username": kcr_username, "password": kcr_password, } - print_kv("镜像凭证", f"{kcr_username}@{request_data['image_credential']['endpoint']}") + request_data["image_credential"] = image_credential + print_kv("镜像凭证", f"{kcr_username}@{image_credential['endpoint']}") else: if registry_kind == "personal_kcr": - print_warn("未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取") + print_warn( + "未配置个人版 KCR 镜像凭证 " + "(KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取" + ) else: print_warn( f"未配置{registry_kind_label(registry_kind)} 镜像凭证 " @@ -670,8 +678,7 @@ async def _build_mcp_async( "artifact_source": ( "built_and_uploaded" if normalized_artifact_type == "Code" and push - else "built_and_pushed" if normalized_artifact_type == "Container" and push - else "built" + else "built_and_pushed" if normalized_artifact_type == "Container" and push else "built" ), "artifact_reused": bool(build_result.metadata.get("reused", False)), "artifact_built": True, @@ -753,7 +760,9 @@ async def _deploy_mcp_async( cached_artifact_reference = None if not artifact_plan.should_clear_metadata: - cached_artifact_reference = load_cached_artifact_reference(mcp_path, normalized_artifact_type) + cached_artifact_reference = load_cached_artifact_reference( + mcp_path, normalized_artifact_type + ) resolved_artifact_plan = resolve_artifact_build_plan( plan=artifact_plan, @@ -869,7 +878,7 @@ async def _deploy_mcp_async( res = await client.update_mcp(existing_mcp_id, request_data) mcp_id = existing_mcp_id else: - # create 默认开公网(network 未显式 enable_public_access 时补 True);update 分支用原始 request_data(network 缺省=保留服务端现有配置) + # create 默认开公网;update 缺省 network 时保留服务端配置。 create_network = dict(request_data.get("network") or {}) if "enable_public_access" not in create_network: create_network["enable_public_access"] = True @@ -935,7 +944,7 @@ def list_mcps(region: str, page: int, size: int, dry_run: bool, output_mode: str _ = output_mode dry_run = effective_dry_run(dry_run) from ksadk.api import AgentEngineClient - + async def _list(): async with AgentEngineClient(region=region, dry_run=dry_run) as client: resp = await client.list_mcps(region=region, page=page, page_size=size) @@ -984,14 +993,14 @@ async def _list(): _abort_mcp_error(e, context="获取列表失败", argv=["mcp", "list"]) -@mcp.command("status", context_settings=CONTEXT_SETTINGS) +@mcp.command("status", context_settings=CONTEXT_SETTINGS, short_help="查看 MCP 状态") @click.argument("mcp_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") @dry_run_option() @cli_output_option() def status(mcp_ref: str | None, region: str | None, dry_run: bool, output_mode: str | None): """查看 MCP 状态 - + MCP_ID: MCP 的 ID """ _ = output_mode @@ -1015,7 +1024,7 @@ def status(mcp_ref: str | None, region: str | None, dry_run: bool, output_mode: target_ref = resolved.value if resolved.source != "cli": print_info(f"未显式指定 MCP,使用 {resolved.source_text}: {target_ref}") - + async def _get(): async with AgentEngineClient(region=region, dry_run=dry_run) as client: mcp = None @@ -1027,7 +1036,7 @@ async def _get(): mcp = await client.get_mcp_by_name(target_ref, region=region) if not mcp: raise resolution_error(f"未找到 MCP: {target_ref}", hints=["agentengine mcp list"]) - + status_text = (mcp.get("status") or "UNKNOWN").upper() fields = [ ("ID", str(mcp.get("mcp_id", "-")), None), @@ -1037,7 +1046,7 @@ async def _get(): ("MCP URL", str(mcp.get("mcp_endpoint", "N/A")), "#58a6ff"), ("认证", "已开启" if mcp.get("enable_auth") else "未开启", None), ] - if mcp.get('tools'): + if mcp.get("tools"): fields.append(("工具", ", ".join(mcp["tools"]), None)) fields.extend( [ @@ -1088,19 +1097,32 @@ def _delete_impl(mcp_ids: tuple[str, ...], region: str, assume_yes: bool, dry_ru prompt=f"确定要删除这 {len(mcp_ids)} 个 MCP 吗?", ): return - + async def _delete(): async with AgentEngineClient(region=region, dry_run=dry_run) as client: failed_ids: list[str] = [] deleted_ids: list[str] = [] + failure_reasons: dict[str, str] = {} for mcp_id in mcp_ids: - success = await client.delete_mcp(mcp_id) + try: + success = await client.delete_mcp(mcp_id) + except DryRunExit: + raise + except Exception as exc: + failed_ids.append(mcp_id) + reason = str(exc) + request_id = str(getattr(exc, "details", {}).get("request_id") or "") + if request_id: + reason = f"{reason} (RequestId: {request_id})" + failure_reasons[mcp_id] = reason + continue if success: deleted_ids.append(mcp_id) print_success(f"MCP 已删除: {mcp_id}") # 尝试清理本地状态文件 (如果在项目目录中) from ksadk.deployment.state import clear_state + try: removed = clear_state(Path("."), key=mcp_id) if removed: @@ -1111,11 +1133,19 @@ async def _delete(): pass else: failed_ids.append(mcp_id) + failure_reasons[mcp_id] = "服务端未确认删除" if failed_ids: + reason_text = "; ".join( + f"{mcp_id}: {failure_reasons[mcp_id]}" for mcp_id in failed_ids + ) raise remote_error( - f"以下 MCP 删除失败: {', '.join(failed_ids)}", - details={"deleted": deleted_ids, "failed": failed_ids}, + f"以下 MCP 删除失败: {', '.join(failed_ids)}。原因: {reason_text}", + details={ + "deleted": deleted_ids, + "failed": failed_ids, + "errors": failure_reasons, + }, ) return { "targets": list(mcp_ids), @@ -1123,7 +1153,7 @@ async def _delete(): "failed": failed_ids, } - dry_run_kwargs = {"dry_run": dry_run} + dry_run_kwargs: dict[str, Any] = {"dry_run": dry_run} if is_json_output(): dry_run_kwargs.update( dry_run_resource="mcp", @@ -1161,7 +1191,9 @@ async def _delete(): @confirm_options() @dry_run_option() @cli_output_option() -def delete(mcp_ids: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None): +def delete( + mcp_ids: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None +): """删除 MCP。""" _ = output_mode _delete_impl(mcp_ids=mcp_ids, region=region, assume_yes=assume_yes, dry_run=dry_run) @@ -1173,7 +1205,9 @@ def delete(mcp_ids: tuple[str, ...], region: str, assume_yes: bool, dry_run: boo @confirm_options() @dry_run_option() @cli_output_option() -def destroy(mcp_ids: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None): +def destroy( + mcp_ids: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None +): """删除 MCP。""" _ = output_mode _delete_impl(mcp_ids=mcp_ids, region=region, assume_yes=assume_yes, dry_run=dry_run) diff --git a/ksadk/cli/cmd_model.py b/ksadk/cli/cmd_model.py index 4ba7819e..b7642d48 100644 --- a/ksadk/cli/cmd_model.py +++ b/ksadk/cli/cmd_model.py @@ -3,15 +3,20 @@ """ import os +from pathlib import Path + import click import httpx import questionary import yaml -from pathlib import Path from dotenv import set_key -from ksadk.deployment.state import load_state + from ksadk.cli.error_utils import abort_with_cli_error, print_exception, usage_error -from ksadk.cli.resource_common import CONTEXT_SETTINGS, CompatibilityAliasCommand, print_compatibility_hint +from ksadk.cli.resource_common import ( + CONTEXT_SETTINGS, + CompatibilityAliasCommand, + print_compatibility_hint, +) from ksadk.cli.ui import ( is_color_disabled, is_stdout_tty, @@ -22,6 +27,7 @@ print_title, print_warn, ) +from ksadk.deployment.state import load_state def _parse_model_selection(raw: str | None) -> list[str]: @@ -71,7 +77,9 @@ def _write_default_model(selected_model: str) -> Path: if not selected_model: raise click.ClickException("未选择模型") env_file = _resolve_env_file() - success, _key, _value = set_key(env_file, "OPENAI_MODEL_NAME", selected_model, quote_mode="never") + success, _key, _value = set_key( + env_file, "OPENAI_MODEL_NAME", selected_model, quote_mode="never" + ) if not success: raise click.ClickException("更新 OPENAI_MODEL_NAME 失败") return env_file @@ -145,7 +153,8 @@ def run_model_command( hints=[ "查看当前模型配置请使用 `agentengine config show`。", "非交互修改请使用 `agentengine config set OPENAI_MODEL_NAME=`。", - "为 Agent/deploy 生成环境变量请使用 `agentengine config model --env `。", + "为 Agent/deploy 生成环境变量请使用 " + "`agentengine config model --env `。", ], ), argv=["config", "model"], diff --git a/ksadk/cli/cmd_openclaw.py b/ksadk/cli/cmd_openclaw.py index 0fd8aaea..a7dcb50c 100644 --- a/ksadk/cli/cmd_openclaw.py +++ b/ksadk/cli/cmd_openclaw.py @@ -10,11 +10,11 @@ from __future__ import annotations +import asyncio import copy import io -import os -import asyncio import json +import os import re import secrets import shutil @@ -26,23 +26,27 @@ import webbrowser from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional, cast import click from click.core import ParameterSource +from rich.console import Console from rich.measure import Measurement from rich.table import Table as RichTable from ksadk.api.client import DryRunExit +from ksadk.builders.container_builder import ( + registry_kind_label, + resolve_registry_credentials, +) from ksadk.cli.agent_ref import resolve_openclaw_ref -from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run +from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run 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 -from ksadk.cli.storage import build_storage_config -from ksadk.cli.resource_common import ResourceActionDescriptor from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, - ResourceActionSet, + ResourceActionDescriptor, ResourceDescriptor, ResourceListSchema, ResourceStatusSchema, @@ -51,18 +55,17 @@ confirm_destructive, confirm_options, pagination_options, - print_next_action_hint, + region_option, render_descriptor_list, render_descriptor_status, - region_option, ) +from ksadk.cli.storage import build_storage_config from ksadk.cli.ui import ( emit_json, get_console, is_json_output, is_stdout_tty, json_dumps, - output_option as cli_output_option, print_info, print_kv, print_rule, @@ -71,17 +74,19 @@ print_warn, status_rich_style, ) -from ksadk.deployment.agent_access import get_latest_agent_access -from ksadk.cli.model_catalog import fetch_provider_model_catalog, find_model_in_catalog +from ksadk.cli.ui import ( + output_option as cli_output_option, +) 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 -from ksadk.builders.container_builder import ( - registry_kind_label, - resolve_registry_credentials, +from ksadk.openclaw_gateway import ( + OpenClawGatewayClient, + OpenClawGatewayError, + OpenClawGatewayRequestError, ) -from ksadk.openclaw_gateway import OpenClawGatewayClient, OpenClawGatewayError, OpenClawGatewayRequestError -from ksadk.terminal_exec_policy import OPENCLAW_TERMINAL_EXEC_POLICY from ksadk.terminal_client import run_terminal_session +from ksadk.terminal_exec_policy import OPENCLAW_TERMINAL_EXEC_POLICY console = get_console() # 默认 OpenClaw 镜像。 @@ -136,7 +141,8 @@ 飞书:启动官方 onboarding 流程。 agentengine openclaw channel connect --channel feishu WPS 协作:写入开放平台 appId/appSecret 并启动长连接。 - agentengine openclaw channel connect --channel wps-xiezuo --app-id --app-secret + agentengine openclaw channel connect --channel wps-xiezuo \ + --app-id --app-secret \b WPS 协作说明: @@ -347,6 +353,7 @@ def _get_global_env() -> Dict[str, str]: try: from ksadk.configs.global_config import get_env_from_global_config + _GLOBAL_ENV_CACHE = { str(k): str(v).strip() for k, v in get_env_from_global_config().items() @@ -395,6 +402,7 @@ def _resolve_model_base_url(cli_value: Optional[str]) -> Optional[str]: try: from ksadk.configs.settings import settings + api_base = settings.model.api_base if api_base and str(api_base).strip(): return str(api_base).strip() @@ -434,8 +442,9 @@ def _summarize_openclaw_region(agents: list[Dict[str, Any]], fallback_region: Op def _print_openclaw_list_summary(table: RichTable, summary_text: str) -> None: """将摘要贴在表格下方;宽度不足时退化成普通单行。""" - table_width = Measurement.get(console, console.options, table).maximum - summary_width = Measurement.get(console, console.options, summary_text).maximum + rich_console = cast(Console, console) + table_width = Measurement.get(rich_console, rich_console.options, table).maximum + summary_width = Measurement.get(rich_console, rich_console.options, summary_text).maximum if table_width >= summary_width: summary_grid = RichTable.grid(expand=False) summary_grid.add_column(justify="right", width=table_width) @@ -456,7 +465,12 @@ def _normalize_ui_locale(raw: Optional[str]) -> str: if low in {"c", "c-utf-8", "c.utf-8", "posix"}: return "zh-CN" - if low.startswith("zh-tw") or low.startswith("zh-hk") or low.startswith("zh-mo") or low.startswith("zh-hant"): + if ( + low.startswith("zh-tw") + or low.startswith("zh-hk") + or low.startswith("zh-mo") + or low.startswith("zh-hant") + ): return "zh-TW" if low.startswith("zh"): return "zh-CN" @@ -531,7 +545,9 @@ def _strip_provider_prefix(provider_id: str, model_id: str) -> str: def _default_openclaw_model_inputs(provider_id: str, model_id: str) -> list[str]: - if str(provider_id or "").strip().lower() == "ksyun" and str(model_id or "").strip().lower() in {"glm-5.1", "glm-5.2"}: + if str(provider_id or "").strip().lower() == "ksyun" and str( + model_id or "" + ).strip().lower() in {"glm-5.1", "glm-5.2"}: return ["text"] return ["text", "image"] @@ -597,9 +613,11 @@ def _openclaw_catalog_item_from_metadata( "api": str(raw_model.get("api") or provider_api or "openai-completions"), "reasoning": bool(raw_model.get("reasoning", True)), "input": inputs or _default_openclaw_model_inputs(provider_id, model_id), - "cost": raw_model.get("cost") - if isinstance(raw_model.get("cost"), dict) - else {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + "cost": ( + raw_model.get("cost") + if isinstance(raw_model.get("cost"), dict) + else {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0} + ), "contextWindow": int(metadata.get("context_window_tokens") or 200_000), "maxTokens": int(metadata.get("max_output_tokens") or 20_000), } @@ -613,7 +631,9 @@ def _apply_openclaw_provider_model_catalog( return False provider_id = str(env.get("OPENCLAW_MODEL_PROVIDER_ID") or "ksyun").strip() or "ksyun" - provider_api = str(env.get("OPENCLAW_MODEL_API") or "openai-completions").strip() or "openai-completions" + provider_api = ( + str(env.get("OPENCLAW_MODEL_API") or "openai-completions").strip() or "openai-completions" + ) raw_catalog = str(env.get("OPENCLAW_MODEL_CATALOG_JSON") or "").strip() catalog: list[Dict[str, Any]] = [] if raw_catalog: @@ -632,16 +652,17 @@ def _apply_openclaw_provider_model_catalog( changed = False for raw_model in raw_models: - if not isinstance(raw_model, dict): - raw_model = {"id": str(raw_model or "").strip()} - item = _openclaw_catalog_item_from_metadata( - raw_model, + raw_model_dict: Dict[str, Any] = ( + dict(raw_model) if isinstance(raw_model, dict) else {"id": str(raw_model or "").strip()} + ) + catalog_item = _openclaw_catalog_item_from_metadata( + raw_model_dict, provider_id=provider_id, provider_api=provider_api, ) - if not item: + if not catalog_item: continue - model_key = str(item["id"]) + model_key = str(catalog_item["id"]) existing_index = next( ( index @@ -651,13 +672,13 @@ def _apply_openclaw_provider_model_catalog( None, ) if existing_index is not None: - merged = {**catalog[existing_index], **item} + merged = {**catalog[existing_index], **catalog_item} if merged != catalog[existing_index]: catalog[existing_index] = merged changed = True continue seen.add(model_key) - catalog.append(item) + catalog.append(catalog_item) changed = True if not catalog or not changed: @@ -679,20 +700,12 @@ def _apply_openclaw_provider_model_metadata( def _openclaw_requested_model_ids(env: Dict[str, str]) -> list[str]: raw_allowlist = str( - env.get("OPENCLAW_MODEL_ALLOWLIST") - or env.get("AGENTENGINE_MODEL_ALLOWLIST") - or "" + env.get("OPENCLAW_MODEL_ALLOWLIST") or env.get("AGENTENGINE_MODEL_ALLOWLIST") or "" ).strip() if raw_allowlist: - return [ - item.strip() - for item in raw_allowlist.replace(";", ",").split(",") - if item.strip() - ] + return [item.strip() for item in raw_allowlist.replace(";", ",").split(",") if item.strip()] primary_model = str( - env.get("OPENCLAW_DEFAULT_MODEL") - or env.get("OPENAI_MODEL_NAME") - or "" + env.get("OPENCLAW_DEFAULT_MODEL") or env.get("OPENAI_MODEL_NAME") or "" ).strip() return [primary_model] if primary_model else [] @@ -705,9 +718,7 @@ def _filter_openclaw_provider_catalog( if not requested_models: return [] raw_models = [ - item.get("_provider_raw_model") or item - if isinstance(item, dict) - else item + item.get("_provider_raw_model") or item if isinstance(item, dict) else item for item in provider_catalog ] selected: list[Any] = [] @@ -716,7 +727,9 @@ def _filter_openclaw_provider_catalog( match = find_model_in_catalog(raw_models, model_id) if match is None: continue - identities = sorted(str(value).strip().lower() for value in (model_id, str(match)) if str(value).strip()) + identities = sorted( + str(value).strip().lower() for value in (model_id, str(match)) if str(value).strip() + ) dedupe_key = identities[0] if identities else str(model_id).lower() if dedupe_key in seen: continue @@ -747,14 +760,12 @@ def _build_openclaw_env_vars( openclaw_explicit_model = default_model or _resolve_env("OPENCLAW_DEFAULT_MODEL") generic_model_preference = _resolve_env("OPENAI_MODEL_NAME", "MODEL_NAME", "LLM_MODEL") model_preference = openclaw_explicit_model or generic_model_preference - explicit_base_url = ( - model_base_url - or _resolve_env("OPENCLAW_MODEL_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE") + explicit_base_url = model_base_url or _resolve_env( + "OPENCLAW_MODEL_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE" ) base_url = _resolve_model_base_url(explicit_base_url) - api_key = ( - model_api_key - or _resolve_env("OPENCLAW_MODEL_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "MODEL_API_KEY") + api_key = model_api_key or _resolve_env( + "OPENCLAW_MODEL_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "MODEL_API_KEY" ) model = model_preference or "glm-5.2" explicit_provider_id = model_provider_id or _resolve_env("OPENCLAW_MODEL_PROVIDER_ID") @@ -762,34 +773,33 @@ def _build_openclaw_env_vars( if not inferred_provider_id and model and "/" in model: inferred_provider_id = model.split("/", 1)[0].strip() provider_id = inferred_provider_id or default_provider_id - resolved_gateway_port = ( - gateway_port - or _resolve_env("OPENCLAW_GATEWAY_PORT", "PORT") - or "8080" - ) - resolved_public_port = ( - public_port - or _resolve_env("OPENCLAW_PUBLIC_PORT") - or "80" - ) + resolved_gateway_port = gateway_port or _resolve_env("OPENCLAW_GATEWAY_PORT", "PORT") or "8080" + resolved_public_port = public_port or _resolve_env("OPENCLAW_PUBLIC_PORT") or "80" explicit_model_api = _resolve_env("OPENCLAW_MODEL_API") model_api = explicit_model_api or default_model_api trusted_proxy_user_header = ( - _resolve_env( - "OPENCLAW_TRUSTED_PROXY_USER_HEADER", - "OPENCLAW_GATEWAY_TRUSTED_PROXY_USER_HEADER", + ( + _resolve_env( + "OPENCLAW_TRUSTED_PROXY_USER_HEADER", + "OPENCLAW_GATEWAY_TRUSTED_PROXY_USER_HEADER", + ) + or DEFAULT_TRUSTED_PROXY_USER_HEADER ) - or DEFAULT_TRUSTED_PROXY_USER_HEADER - ).strip().lower() + .strip() + .lower() + ) internal_trusted_proxy_user = ( - _resolve_env("OPENCLAW_INTERNAL_TRUSTED_PROXY_USER") - or "openclaw-backend" + _resolve_env("OPENCLAW_INTERNAL_TRUSTED_PROXY_USER") or "openclaw-backend" ) internal_trusted_proxy_user_header = ( - _resolve_env("OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER") - or trusted_proxy_user_header - or DEFAULT_TRUSTED_PROXY_USER_HEADER - ).strip().lower() + ( + _resolve_env("OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER") + or trusted_proxy_user_header + or DEFAULT_TRUSTED_PROXY_USER_HEADER + ) + .strip() + .lower() + ) trusted_proxies = _normalize_csv_list( _resolve_env("OPENCLAW_TRUSTED_PROXIES") or "", default_items=DEFAULT_TRUSTED_PROXY_CIDRS, @@ -797,7 +807,9 @@ def _build_openclaw_env_vars( browser_enabled = _resolve_env("OPENCLAW_BROWSER_ENABLED") browser_no_sandbox = _resolve_env("OPENCLAW_BROWSER_NO_SANDBOX") or "true" browser_headless = _resolve_env("OPENCLAW_BROWSER_HEADLESS") or "true" - browser_executable = _resolve_env("OPENCLAW_BROWSER_EXECUTABLE_PATH", "OPENCLAW_BROWSER_EXECUTABLE") + browser_executable = _resolve_env( + "OPENCLAW_BROWSER_EXECUTABLE_PATH", "OPENCLAW_BROWSER_EXECUTABLE" + ) ui_locale = _normalize_ui_locale(_resolve_env("OPENCLAW_UI_LOCALE", "LANG", "LC_ALL")) exec_strict_mode_raw = ( exec_profile_overrides.get("OPENCLAW_EXEC_STRICT_MODE") @@ -806,13 +818,21 @@ def _build_openclaw_env_vars( ) exec_strict_mode = _is_truthy(exec_strict_mode_raw) - exec_host = exec_profile_overrides.get("OPENCLAW_EXEC_HOST") or _resolve_env("OPENCLAW_EXEC_HOST") or "gateway" + exec_host = ( + exec_profile_overrides.get("OPENCLAW_EXEC_HOST") + or _resolve_env("OPENCLAW_EXEC_HOST") + or "gateway" + ) exec_security = ( exec_profile_overrides.get("OPENCLAW_EXEC_SECURITY") or _resolve_env("OPENCLAW_EXEC_SECURITY") or ("allowlist" if exec_strict_mode else "full") ) - exec_ask = exec_profile_overrides.get("OPENCLAW_EXEC_ASK") or _resolve_env("OPENCLAW_EXEC_ASK") or "off" + exec_ask = ( + exec_profile_overrides.get("OPENCLAW_EXEC_ASK") + or _resolve_env("OPENCLAW_EXEC_ASK") + or "off" + ) exec_ask_fallback = ( exec_profile_overrides.get("OPENCLAW_EXEC_ASK_FALLBACK") or _resolve_env("OPENCLAW_EXEC_ASK_FALLBACK") @@ -831,9 +851,7 @@ def _build_openclaw_env_vars( exec_default_allowlist_enabled = ( exec_profile_overrides.get("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED") or _resolve_env("OPENCLAW_EXEC_DEFAULT_ALLOWLIST_ENABLED") - or ( - "true" if exec_strict_mode else "false" - ) + or ("true" if exec_strict_mode else "false") ) exec_allowlist = _resolve_env("OPENCLAW_EXEC_ALLOWLIST") fs_workspace_only = ( @@ -850,10 +868,14 @@ def _build_openclaw_env_vars( env["OPENCLAW_GATEWAY_BIND"] = "lan" if gateway_auth_mode: env["OPENCLAW_GATEWAY_AUTH_MODE"] = gateway_auth_mode - env["OPENCLAW_TRUSTED_PROXY_USER_HEADER"] = trusted_proxy_user_header or DEFAULT_TRUSTED_PROXY_USER_HEADER + env["OPENCLAW_TRUSTED_PROXY_USER_HEADER"] = ( + trusted_proxy_user_header or DEFAULT_TRUSTED_PROXY_USER_HEADER + ) env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER"] = internal_trusted_proxy_user env["OPENCLAW_INTERNAL_TRUSTED_PROXY_USER_HEADER"] = ( - internal_trusted_proxy_user_header or trusted_proxy_user_header or DEFAULT_TRUSTED_PROXY_USER_HEADER + internal_trusted_proxy_user_header + or trusted_proxy_user_header + or DEFAULT_TRUSTED_PROXY_USER_HEADER ) env["OPENCLAW_TRUSTED_PROXIES"] = trusted_proxies env["OPENCLAW_GATEWAY_PORT"] = str(resolved_gateway_port) @@ -899,8 +921,9 @@ def _build_openclaw_env_vars( _, catalog_model_id = normalized_model.split("/", 1) resolved_model = normalized_model else: - catalog_model_id = normalized_model - resolved_model = f"{provider_id}/{normalized_model}" if provider_id else normalized_model + resolved_model = ( + f"{provider_id}/{normalized_model}" if provider_id else normalized_model + ) if openclaw_explicit_model: env["OPENCLAW_DEFAULT_MODEL"] = resolved_model elif generic_model_preference: @@ -1005,11 +1028,14 @@ def _normalize_openclaw_gateway_auth_env(env: dict[str, str]) -> dict[str, str]: auth_mode = raw_mode or ("token" if raw_token or raw_password else "trusted-proxy") if auth_mode == "token": if raw_token and raw_password and raw_token != raw_password: - raise ValueError("OPENCLAW_GATEWAY_TOKEN 与 OPENCLAW_GATEWAY_PASSWORD 同时提供时必须一致") + raise ValueError( + "OPENCLAW_GATEWAY_TOKEN 与 OPENCLAW_GATEWAY_PASSWORD 同时提供时必须一致" + ) shared_secret = raw_token or raw_password if not shared_secret: raise ValueError( - "OPENCLAW_GATEWAY_AUTH_MODE=token 时必须提供 OPENCLAW_GATEWAY_TOKEN 或 OPENCLAW_GATEWAY_PASSWORD" + "OPENCLAW_GATEWAY_AUTH_MODE=token 时必须提供 " + "OPENCLAW_GATEWAY_TOKEN 或 OPENCLAW_GATEWAY_PASSWORD" ) normalized_env["OPENCLAW_GATEWAY_AUTH_MODE"] = "token" normalized_env["OPENCLAW_GATEWAY_TOKEN"] = shared_secret @@ -1018,7 +1044,8 @@ def _normalize_openclaw_gateway_auth_env(env: dict[str, str]) -> dict[str, str]: if raw_token or raw_password: raise ValueError( - "仅在 OPENCLAW_GATEWAY_AUTH_MODE=token 时支持 OPENCLAW_GATEWAY_TOKEN 或 OPENCLAW_GATEWAY_PASSWORD" + "仅在 OPENCLAW_GATEWAY_AUTH_MODE=token 时支持 " + "OPENCLAW_GATEWAY_TOKEN 或 OPENCLAW_GATEWAY_PASSWORD" ) normalized_env["OPENCLAW_GATEWAY_AUTH_MODE"] = auth_mode @@ -1174,7 +1201,7 @@ async def _fetch_bootstrap_config(region: str) -> Optional[Dict[str, Any]]: try: async with AgentEngineClient(region=region) as client: - return await client.get_client_bootstrap_config( + result = await client.get_client_bootstrap_config( product="openclaw", framework="openclaw", region=region, @@ -1183,6 +1210,7 @@ async def _fetch_bootstrap_config(region: str) -> Optional[Dict[str, Any]]: locale=_resolve_env("OPENCLAW_UI_LOCALE", "LANG", "LC_ALL"), ignore_dry_run=True, ) + return dict(result) if isinstance(result, dict) else None except Exception as e: print_warn(f"拉取服务端默认配置失败,回退本地默认镜像: {e}") return None @@ -1253,14 +1281,22 @@ def _flatten_agent_detail(agent: dict) -> dict: "agent_id": basic.get("agent_id") or agent.get("agent_id") or "", "name": basic.get("name") or agent.get("name") or "", "status": (basic.get("status") or agent.get("status") or "UNKNOWN").upper(), - "framework": basic.get("framework") or deploy.get("framework") or agent.get("framework") or "", + "framework": basic.get("framework") + or deploy.get("framework") + or agent.get("framework") + or "", "region": basic.get("region") or deploy.get("region") or agent.get("region") or "", - "endpoint": quick.get("public_endpoint") or quick.get("private_endpoint") or agent.get("endpoint") or "", + "endpoint": quick.get("public_endpoint") + or quick.get("private_endpoint") + or agent.get("endpoint") + or "", "artifact_path": deploy.get("artifact_path") or agent.get("artifact_path") or "", "created_at": basic.get("created_at") or agent.get("created_at") or "", "updated_at": basic.get("updated_at") or agent.get("updated_at") or "", "api_key": quick.get("api_key") or agent.get("api_key"), - "langfuse_url": (agent.get("advanced") or {}).get("observability_url") or agent.get("langfuse_trace_url") or "", + "langfuse_url": (agent.get("advanced") or {}).get("observability_url") + or agent.get("langfuse_trace_url") + or "", } @@ -1277,7 +1313,9 @@ async def _get_openclaw_detail_with_client( detail = _flatten_agent_detail(agent) framework = str(detail.get("framework") or "").strip().lower() if framework and framework != "openclaw": - raise resolution_error(f"目标 Agent 不是 OpenClaw: {agent_ref}", hints=["agentengine openclaw list"]) + raise resolution_error( + f"目标 Agent 不是 OpenClaw: {agent_ref}", hints=["agentengine openclaw list"] + ) return detail @@ -1303,10 +1341,7 @@ def _resolve_region( ) -> str: """解析 region: 显式参数 > state > 环境变量 > 默认值。""" return ( - cli_region - or (state or {}).get("region") - or _resolve_env("KSYUN_REGION") - or "cn-beijing-6" + cli_region or (state or {}).get("region") or _resolve_env("KSYUN_REGION") or "cn-beijing-6" ) @@ -1331,7 +1366,9 @@ async def _resolve_openclaw_detail_or_raise( detail = await _get_openclaw_detail_with_client(client, resolved.value) if not detail: - raise resolution_error(f"未找到 OpenClaw: {resolved.value}", hints=["agentengine openclaw list"]) + raise resolution_error( + f"未找到 OpenClaw: {resolved.value}", hints=["agentengine openclaw list"] + ) return resolved_region, detail @@ -1378,7 +1415,9 @@ async def _ensure_openclaw_gateway_available( await _wait_for_gateway_ready(region, detail, timeout_seconds=timeout_seconds) -def _emit_data_payload(title: str, payload: dict[str, Any], *, subtitle: Optional[str] = None) -> None: +def _emit_data_payload( + title: str, payload: dict[str, Any], *, subtitle: Optional[str] = None +) -> None: if is_json_output(): emit_json(payload) return @@ -1386,7 +1425,9 @@ def _emit_data_payload(title: str, payload: dict[str, Any], *, subtitle: Optiona console.print(json_dumps(payload), markup=False) -def _render_openclaw_dry_run(action: str, request: dict[str, Any], hints: tuple[str, ...] = ()) -> None: +def _render_openclaw_dry_run( + action: str, request: dict[str, Any], hints: tuple[str, ...] = () +) -> None: if is_json_output(): emit_json( build_dry_run_envelope( @@ -1456,7 +1497,9 @@ def _select_openclaw_gateway_secret( or "" ).strip() if token and password and token != password: - raise click.ClickException("OPENCLAW_GATEWAY_TOKEN 与 OPENCLAW_GATEWAY_PASSWORD 同时提供时必须一致") + raise click.ClickException( + "OPENCLAW_GATEWAY_TOKEN 与 OPENCLAW_GATEWAY_PASSWORD 同时提供时必须一致" + ) if token: return "token", token if password: @@ -1473,14 +1516,20 @@ def _mask_openclaw_secret(secret: str | None) -> str: return f"{text[:4]}****" -def _openclaw_auth_mode_from_sources(detail: dict[str, Any], state: dict[str, Any] | None = None) -> str: - return str( - detail.get("openclaw_auth_mode") - or detail.get("gateway_auth_mode") - or (state or {}).get("openclaw_auth_mode") - or (state or {}).get("gateway_auth_mode") - or "" - ).strip().lower() +def _openclaw_auth_mode_from_sources( + detail: dict[str, Any], state: dict[str, Any] | None = None +) -> str: + return ( + str( + detail.get("openclaw_auth_mode") + or detail.get("gateway_auth_mode") + or (state or {}).get("openclaw_auth_mode") + or (state or {}).get("gateway_auth_mode") + or "" + ) + .strip() + .lower() + ) def _openclaw_state_gateway_token(env_vars: dict[str, str]) -> str | None: @@ -1488,9 +1537,7 @@ def _openclaw_state_gateway_token(env_vars: dict[str, str]) -> str | None: if str(env_vars.get("OPENCLAW_GATEWAY_AUTH_MODE") or "").strip().lower() != "token": return None token = str( - env_vars.get("OPENCLAW_GATEWAY_TOKEN") - or env_vars.get("OPENCLAW_GATEWAY_PASSWORD") - or "" + env_vars.get("OPENCLAW_GATEWAY_TOKEN") or env_vars.get("OPENCLAW_GATEWAY_PASSWORD") or "" ).strip() return token or None @@ -1523,7 +1570,9 @@ async def _run_weixin_remote_cli_login( if not endpoint: raise OpenClawGatewayError("OpenClaw runtime endpoint 为空,无法通过远端 CLI 执行微信登录") - print_info(f"当前 OpenClaw 未暴露微信 web login RPC,改用远端 OpenClaw CLI 登录流程({reason})") + print_info( + f"当前 OpenClaw 未暴露微信 web login RPC,改用远端 OpenClaw CLI 登录流程({reason})" + ) exit_code = await run_terminal_session( endpoint=endpoint, api_key=_openclaw_terminal_api_key(detail), @@ -1592,9 +1641,8 @@ async def _wait_for_gateway_reload_after_config_apply( def _is_gateway_reload_disconnect_error(exc: Exception) -> bool: message = str(exc).lower() - return ( - "websocket receive failed" in message - and ("1011" in message or "bad gateway" in message or "going away" in message) + return "websocket receive failed" in message and ( + "1011" in message or "bad gateway" in message or "going away" in message ) @@ -1684,9 +1732,15 @@ def _is_channel_configured( accounts = channel_cfg.get("accounts") return isinstance(accounts, dict) and any(str(key).strip() for key in accounts.keys()) if channel == "feishu": - return bool(str(channel_cfg.get("appId") or "").strip() and str(channel_cfg.get("appSecret") or "").strip()) + return bool( + str(channel_cfg.get("appId") or "").strip() + and str(channel_cfg.get("appSecret") or "").strip() + ) if channel == "wps-xiezuo": - return bool(str(channel_cfg.get("appId") or "").strip() and str(channel_cfg.get("appSecret") or "").strip()) + return bool( + str(channel_cfg.get("appId") or "").strip() + and str(channel_cfg.get("appSecret") or "").strip() + ) return bool(channel_cfg) @@ -1791,7 +1845,9 @@ def _ensure_local_node_tools() -> dict[str, str]: if not node_path or not npx_path: raise resolution_error( "本地缺少 `node` 或 `npx`,飞书接入依赖官方 onboarding 工具", - hints=["先安装 Node.js,然后重试 `agentengine openclaw channel connect --channel feishu`"], + hints=[ + "先安装 Node.js,然后重试 `agentengine openclaw channel connect --channel feishu`" + ], ) return {"node": node_path, "npx": npx_path} @@ -1822,14 +1878,11 @@ async def _resolve_npx_cached_package_file(package_spec: str, relative_path: str text=True, ) if completed.returncode != 0: - raise OpenClawGatewayError( - f"无法准备官方 npm 包缓存: {completed.stderr.strip() or completed.stdout.strip() or package_spec}" - ) + npm_error = completed.stderr.strip() or completed.stdout.strip() or package_spec + raise OpenClawGatewayError(f"无法准备官方 npm 包缓存: {npm_error}") npm_cache_root = Path( - os.getenv("NPM_CONFIG_CACHE") - or os.getenv("npm_config_cache") - or str(Path.home() / ".npm") + os.getenv("NPM_CONFIG_CACHE") or os.getenv("npm_config_cache") or str(Path.home() / ".npm") ) pattern = f"_npx/**/node_modules/{package_name}/{relative_path}" matches = list(npm_cache_root.glob(pattern)) @@ -1847,8 +1900,7 @@ async def _run_feishu_onboarding(existing_app_id: Optional[str]) -> dict[str, An "@larksuite/openclaw-lark-tools@latest", "dist/utils/install-prompts.js", ) - script_body = textwrap.dedent( - """ + script_body = textwrap.dedent(""" const fs = require("fs"); const { runInstallAuthFlow } = require(__INSTALL_PROMPTS_PATH__); @@ -1860,19 +1912,26 @@ async def _run_feishu_onboarding(existing_app_id: Optional[str]) -> dict[str, An {}, false, ); - fs.writeFileSync(process.env.KSADK_FEISHU_RESULT_PATH, JSON.stringify(result), "utf8"); + fs.writeFileSync( + process.env.KSADK_FEISHU_RESULT_PATH, + JSON.stringify(result), + "utf8", + ); } catch (error) { console.error(error); process.exit(1); } })(); - """ - ).replace("__INSTALL_PROMPTS_PATH__", json.dumps(str(install_prompts_path))) - with tempfile.NamedTemporaryFile("w", suffix=".cjs", delete=False, encoding="utf-8") as script_file: + """).replace("__INSTALL_PROMPTS_PATH__", json.dumps(str(install_prompts_path))) + with tempfile.NamedTemporaryFile( + "w", suffix=".cjs", delete=False, encoding="utf-8" + ) as script_file: script_file.write(script_body.strip()) script_path = script_file.name - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as result_file: + with tempfile.NamedTemporaryFile( + "w", suffix=".json", delete=False, encoding="utf-8" + ) as result_file: result_path = result_file.name env = os.environ.copy() @@ -1945,7 +2004,9 @@ def _resolve_weixin_account_id( raise click.ClickException("检测到多个微信账号,请显式传入 --account-id") if create_if_missing: return "default" - raise click.ClickException("尚未检测到微信账号,请先执行 `agentengine openclaw channel connect --channel weixin`") + raise click.ClickException( + "尚未检测到微信账号,请先执行 `agentengine openclaw channel connect --channel weixin`" + ) def _mutate_weixin_account_enabled( @@ -2011,7 +2072,8 @@ def _mutate_feishu_connect_config(config: dict[str, Any], onboarding: dict[str, feishu_cfg[key] = value changed = True - user_info = onboarding.get("userInfo") if isinstance(onboarding.get("userInfo"), dict) else {} + user_info_value = onboarding.get("userInfo") + user_info: dict[str, Any] = dict(user_info_value) if isinstance(user_info_value, dict) else {} open_id = str(user_info.get("openId") or "").strip() if open_id: if feishu_cfg.get("dmPolicy") != "allowlist": @@ -2105,8 +2167,12 @@ def _mutate_wps_xiezuo_connect_config( bindings = config.setdefault("bindings", []) if isinstance(bindings, list): next_bindings = [ - item for item in bindings - if not (isinstance(item, dict) and item.get("match", {}).get("channel") == WPS_XIEZUO_CHANNEL_KEY) + item + for item in bindings + if not ( + isinstance(item, dict) + and item.get("match", {}).get("channel") == WPS_XIEZUO_CHANNEL_KEY + ) ] desired_binding = { "type": "route", @@ -2130,7 +2196,9 @@ def _mutate_wps_xiezuo_enabled( ) -> bool: normalized_account = str(account_id or WPS_XIEZUO_DEFAULT_ACCOUNT_ID).strip() if normalized_account and normalized_account != WPS_XIEZUO_DEFAULT_ACCOUNT_ID: - raise click.ClickException("WPS 协作插件使用扁平 channel 配置,`--account-id` 仅支持 default") + raise click.ClickException( + "WPS 协作插件使用扁平 channel 配置,`--account-id` 仅支持 default" + ) changed = _ensure_plugin_enabled(config, WPS_XIEZUO_PLUGIN_ID) if enabled else False channels = config.setdefault("channels", {}) channel_cfg = channels.setdefault(WPS_XIEZUO_CHANNEL_KEY, {}) @@ -2152,7 +2220,9 @@ def _check_wps_xiezuo_local_deps() -> dict[str, Any]: } -@click.group("openclaw", context_settings=CONTEXT_SETTINGS, help=build_resource_group_help(OPENCLAW_RESOURCE)) +@click.group( + "openclaw", context_settings=CONTEXT_SETTINGS, help=build_resource_group_help(OPENCLAW_RESOURCE) +) def openclaw(): pass @@ -2211,8 +2281,15 @@ async def _run_openclaw_repair_action( @click.option("--session", "-s", default=None, help="OpenClaw Session key") @click.option("--message", "-m", default=None, help="连接后发送的初始消息") @click.option("--thinking", default=None, help="Thinking level override") -@click.option("--history-limit", type=click.IntRange(1, 10000), default=None, help="历史条数 (默认使用 OpenClaw 默认值)") -@click.option("--timeout-ms", type=click.IntRange(1, 86400000), default=None, help="Agent timeout ms") +@click.option( + "--history-limit", + type=click.IntRange(1, 10000), + default=None, + help="历史条数 (默认使用 OpenClaw 默认值)", +) +@click.option( + "--timeout-ms", type=click.IntRange(1, 86400000), default=None, help="Agent timeout ms" +) @click.option("--deliver", is_flag=True, help="Deliver assistant replies") @click.option("--insecure", "-k", is_flag=True, help="跳过 SSL 证书验证") @dry_run_option() @@ -2279,10 +2356,14 @@ def tui_openclaw( elif not agent_ref and str(state.get("endpoint") or "").strip(): resolved_endpoint = str(state.get("endpoint") or "").strip() else: - resolved_region, detail = asyncio.run(_resolve_openclaw_detail_or_raise(agent_ref, region=region)) + resolved_region, detail = asyncio.run( + _resolve_openclaw_detail_or_raise(agent_ref, region=region) + ) resolved_endpoint = str(detail.get("endpoint") or "").strip() if not resolved_endpoint: - raise click.ClickException("未解析到 OpenClaw Endpoint,请传入 --endpoint 或指定 OpenClaw ID/名称") + raise click.ClickException( + "未解析到 OpenClaw Endpoint,请传入 --endpoint 或指定 OpenClaw ID/名称" + ) secret_kind, gateway_secret = _select_openclaw_gateway_secret( gateway_token=gateway_token, @@ -2293,18 +2374,26 @@ def tui_openclaw( auth_mode = _openclaw_auth_mode_from_sources(detail, state) if auth_mode in {"token", "password"} and not gateway_secret: raise click.ClickException( - "当前 OpenClaw Gateway 为 token/password 模式,agentengine openclaw tui 需要 OpenClaw Gateway token/password。\n" + "当前 OpenClaw Gateway 为 token/password 模式," + "agentengine openclaw tui 需要 OpenClaw Gateway token/password。\n" "请使用: agentengine openclaw tui --gateway-token \n" "或设置: OPENCLAW_GATEWAY_TOKEN= agentengine openclaw tui\n" - "如果部署时传过 OPENCLAW_GATEWAY_TOKEN,请重新运行部署让本地 .agentengine.state 记录该 token。\n" + "如果部署时传过 OPENCLAW_GATEWAY_TOKEN,请重新运行部署让本地 " + ".agentengine.state 记录该 token。\n" "注意:这里不是 AgentEngine API Key(ak-*)。" ) - terminal_api_key = gateway_secret or api_key or str(detail.get("api_key") or state.get("api_key") or "").strip() or None + terminal_api_key = ( + gateway_secret + or api_key + or str(detail.get("api_key") or state.get("api_key") or "").strip() + or None + ) click.secho("🖥️ OpenClaw Native Remote TUI", fg="blue", bold=True) click.echo(f" Endpoint: {resolved_endpoint}") if gateway_secret: - click.echo(f" Runtime Auth: OpenClaw Gateway {secret_kind} {_mask_openclaw_secret(gateway_secret)}") + masked_gateway_secret = _mask_openclaw_secret(gateway_secret) + click.echo(f" Runtime Auth: OpenClaw Gateway {secret_kind} {masked_gateway_secret}") elif terminal_api_key: click.echo(f" Runtime Auth: Bearer {_mask_openclaw_secret(terminal_api_key)}") else: @@ -2337,7 +2426,9 @@ def tui_openclaw( @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") @click.option("--no-open", is_flag=True, help="仅打印 URL,不自动打开浏览器") @cli_output_option() -def gateway_open(agent_ref: Optional[str], region: Optional[str], no_open: bool, output_mode: str | None): +def gateway_open( + agent_ref: Optional[str], region: Optional[str], no_open: bool, output_mode: str | None +): """打开 OpenClaw gateway Dashboard。""" _ = output_mode no_open = no_open or is_json_output() @@ -2408,19 +2499,29 @@ async def _run(): "auth_mode": "cookie-session", "note": "该 ws-url 依赖短链 cookie session,不承诺长期复用。", } - _emit_data_payload("OpenClaw Gateway 连接信息", payload, subtitle=str(detail.get("name") or "-")) + _emit_data_payload( + "OpenClaw Gateway 连接信息", payload, subtitle=str(detail.get("name") or "-") + ) try: asyncio.run(_run()) except Exception as e: - _abort_openclaw_error(e, context="获取 gateway ws-url 失败", argv=["openclaw", "gateway", "ws-url"]) + _abort_openclaw_error( + e, context="获取 gateway ws-url 失败", argv=["openclaw", "gateway", "ws-url"] + ) @openclaw_gateway.command("logs", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") @click.option("--instance", default=None, help="实例名;不填则查询全部实例") -@click.option("--log-type", type=click.Choice(["stdout", "log"], case_sensitive=False), default="stdout", show_default=True, help="日志类型") +@click.option( + "--log-type", + type=click.Choice(["stdout", "log"], case_sensitive=False), + default="stdout", + show_default=True, + help="日志类型", +) @click.option("--start-time", default=None, help="开始时间,支持 Unix 毫秒或 ISO-8601") @click.option("--end-time", default=None, help="结束时间,支持 Unix 毫秒或 ISO-8601") @cli_output_option() @@ -2465,7 +2566,9 @@ async def _run(): emit_json(payload) return - print_title("OpenClaw Gateway 日志", str(detail.get("name") or detail.get("agent_id") or "-")) + print_title( + "OpenClaw Gateway 日志", str(detail.get("name") or detail.get("agent_id") or "-") + ) print_kv("实例", str(resp.get("instance") or "all")) print_kv("日志类型", str(resp.get("log_type") or log_type)) print_kv("日志条数", str(resp.get("total") or 0)) @@ -2479,13 +2582,20 @@ async def _run(): try: asyncio.run(_run()) except Exception as e: - _abort_openclaw_error(e, context="获取 gateway 日志失败", argv=["openclaw", "gateway", "logs"]) + _abort_openclaw_error( + e, context="获取 gateway 日志失败", argv=["openclaw", "gateway", "logs"] + ) @openclaw_gateway.command("doctor", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") -@click.option("--fix", "do_fix", is_flag=True, help="不走 gateway 内诊断,改为通过控制面执行 openclaw doctor --fix") +@click.option( + "--fix", + "do_fix", + is_flag=True, + help="不走 gateway 内诊断,改为通过控制面执行 openclaw doctor --fix", +) @cli_output_option() def gateway_doctor( agent_ref: Optional[str], @@ -2519,7 +2629,7 @@ async def _run() -> dict[str, Any]: "ws_url": info.ws_url, } ) - hello = await gateway.connect() + await gateway.connect() checks.append( { "name": "cookie_ws_handshake", @@ -2606,7 +2716,12 @@ def openclaw_channel(): @openclaw_channel.command("status", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") -@click.option("--channel", type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), default=None, help="指定 channel") +@click.option( + "--channel", + type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), + default=None, + help="指定 channel", +) @click.option("--probe", is_flag=True, help="触发远端 probe 刷新 channel 快照") @cli_output_option() def channel_status( @@ -2626,7 +2741,9 @@ async def _run(): gateway = _build_gateway_client(resolved_region, detail) try: await gateway.connect() - snapshot = await gateway.channels_status(probe=probe, timeout_ms=8_000 if probe else None) + snapshot = await gateway.channels_status( + probe=probe, timeout_ms=8_000 if probe else None + ) finally: await gateway.close() @@ -2640,26 +2757,67 @@ async def _run(): "snapshot": snapshot, "selected": _extract_channel_snapshot(snapshot, normalized_channel), } - _emit_data_payload("OpenClaw Channel 状态", payload, subtitle=str(detail.get("name") or "-")) + _emit_data_payload( + "OpenClaw Channel 状态", payload, subtitle=str(detail.get("name") or "-") + ) try: asyncio.run(_run()) except Exception as e: - _abort_openclaw_error(e, context="获取 channel 状态失败", argv=["openclaw", "channel", "status"]) + _abort_openclaw_error( + e, context="获取 channel 状态失败", argv=["openclaw", "channel", "status"] + ) -@openclaw_channel.command("connect", context_settings=CONTEXT_SETTINGS, help=OPENCLAW_CHANNEL_CONNECT_HELP) +@openclaw_channel.command( + "connect", context_settings=CONTEXT_SETTINGS, help=OPENCLAW_CHANNEL_CONNECT_HELP +) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") -@click.option("--channel", "channel_name", type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), required=True, help="目标 channel") +@click.option( + "--channel", + "channel_name", + type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), + required=True, + help="目标 channel", +) @click.option("--open-qr", is_flag=True, help="仅微信:在本地浏览器额外打开二维码链接") @click.option("--app-id", "wps_xiezuo_app_id", default=None, help="仅 WPS 协作:开放平台应用 ID") -@click.option("--app-secret", "wps_xiezuo_app_secret", default=None, help="仅 WPS 协作:开放平台应用密钥") -@click.option("--account-id", "wps_xiezuo_account_id", default="default", help="仅 WPS 协作:账号 ID;当前只支持 default") -@click.option("--agent-id", "wps_xiezuo_agent_id", default="main", help="仅 WPS 协作:消息路由到的 OpenClaw agentId") -@click.option("--dm-policy", "wps_xiezuo_dm_policy", type=click.Choice(("disabled", "open", "pairing", "allowlist")), default="pairing", help="仅 WPS 协作:私聊策略,默认 pairing") -@click.option("--group-policy", "wps_xiezuo_group_policy", type=click.Choice(("open", "allowlist")), default="open", help="仅 WPS 协作:群聊策略,默认 open") -@click.option("--base-url", "wps_xiezuo_base_url", default="https://openapi.wps.cn", help="仅 WPS 协作:WPS OpenAPI 基础地址") +@click.option( + "--app-secret", "wps_xiezuo_app_secret", default=None, help="仅 WPS 协作:开放平台应用密钥" +) +@click.option( + "--account-id", + "wps_xiezuo_account_id", + default="default", + help="仅 WPS 协作:账号 ID;当前只支持 default", +) +@click.option( + "--agent-id", + "wps_xiezuo_agent_id", + default="main", + help="仅 WPS 协作:消息路由到的 OpenClaw agentId", +) +@click.option( + "--dm-policy", + "wps_xiezuo_dm_policy", + type=click.Choice(("disabled", "open", "pairing", "allowlist")), + default="pairing", + help="仅 WPS 协作:私聊策略,默认 pairing", +) +@click.option( + "--group-policy", + "wps_xiezuo_group_policy", + type=click.Choice(("open", "allowlist")), + default="open", + help="仅 WPS 协作:群聊策略,默认 open", +) +@click.option( + "--base-url", + "wps_xiezuo_base_url", + default="https://openapi.wps.cn", + help="仅 WPS 协作:WPS OpenAPI 基础地址", +) def channel_connect( agent_ref: Optional[str], region: Optional[str], @@ -2779,13 +2937,18 @@ async def _run(): if normalized_channel == "wps-xiezuo": app_id = str(wps_xiezuo_app_id or "").strip() app_secret = str(wps_xiezuo_app_secret or "").strip() - account_id = str(wps_xiezuo_account_id or WPS_XIEZUO_DEFAULT_ACCOUNT_ID).strip() or WPS_XIEZUO_DEFAULT_ACCOUNT_ID + account_id = ( + str(wps_xiezuo_account_id or WPS_XIEZUO_DEFAULT_ACCOUNT_ID).strip() + or WPS_XIEZUO_DEFAULT_ACCOUNT_ID + ) if not app_id: raise click.ClickException("连接 WPS 协作 channel 必须提供 --app-id") if not app_secret: raise click.ClickException("连接 WPS 协作 channel 必须提供 --app-secret") if account_id != WPS_XIEZUO_DEFAULT_ACCOUNT_ID: - raise click.ClickException("WPS 协作插件使用扁平 channel 配置,`--account-id` 仅支持 default") + raise click.ClickException( + "WPS 协作插件使用扁平 channel 配置,`--account-id` 仅支持 default" + ) apply_gateway = _build_gateway_client(resolved_region, detail) changed = False try: @@ -2800,7 +2963,8 @@ async def _run(): agent_id=str(wps_xiezuo_agent_id or "main").strip() or "main", dm_policy=wps_xiezuo_dm_policy, group_policy=wps_xiezuo_group_policy, - base_url=str(wps_xiezuo_base_url or "https://openapi.wps.cn").strip() or "https://openapi.wps.cn", + base_url=str(wps_xiezuo_base_url or "https://openapi.wps.cn").strip() + or "https://openapi.wps.cn", ) if changed: await _config_apply_and_wait_for_reload( @@ -2842,9 +3006,12 @@ async def _run(): await bootstrap_gateway.close() config, _ = _extract_config_state(cfg_snapshot) - existing_app_id = str( - ((config.get("channels") or {}).get(FEISHU_CHANNEL_KEY) or {}).get("appId") or "" - ).strip() or None + existing_app_id = ( + str( + ((config.get("channels") or {}).get(FEISHU_CHANNEL_KEY) or {}).get("appId") or "" + ).strip() + or None + ) onboarding = await _run_feishu_onboarding(existing_app_id) changed_config = copy.deepcopy(config) changed = _mutate_feishu_connect_config(changed_config, onboarding) @@ -2885,7 +3052,9 @@ async def _run(): try: asyncio.run(_run()) except Exception as e: - _abort_openclaw_error(e, context="channel connect 失败", argv=["openclaw", "channel", "connect"]) + _abort_openclaw_error( + e, context="channel connect 失败", argv=["openclaw", "channel", "connect"] + ) def _run_channel_toggle_command( @@ -2954,18 +3123,28 @@ async def _run(): "enabled": enabled, "status": _extract_channel_snapshot(snapshot, normalized_channel), } - _emit_data_payload(f"OpenClaw Channel {action.title()}", payload, subtitle=normalized_channel) + _emit_data_payload( + f"OpenClaw Channel {action.title()}", payload, subtitle=normalized_channel + ) try: asyncio.run(_run()) except Exception as e: - _abort_openclaw_error(e, context=f"channel {action} 失败", argv=["openclaw", "channel", action]) + _abort_openclaw_error( + e, context=f"channel {action} 失败", argv=["openclaw", "channel", action] + ) @openclaw_channel.command("enable", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") -@click.option("--channel", "channel_name", type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), required=True, help="目标 channel") +@click.option( + "--channel", + "channel_name", + type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), + required=True, + help="目标 channel", +) @click.option("--account-id", default=None, help="账号 ID;飞书 V1 仅支持 default") def channel_enable( agent_ref: Optional[str], @@ -2987,7 +3166,13 @@ def channel_enable( @openclaw_channel.command("disable", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") -@click.option("--channel", "channel_name", type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), required=True, help="目标 channel") +@click.option( + "--channel", + "channel_name", + type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), + required=True, + help="目标 channel", +) @click.option("--account-id", default=None, help="账号 ID;飞书 V1 仅支持 default") def channel_disable( agent_ref: Optional[str], @@ -3009,9 +3194,16 @@ def channel_disable( @openclaw_channel.command("doctor", context_settings=CONTEXT_SETTINGS) @click.argument("agent_ref", required=False) @region_option(default=None, envvar=None, help_text="区域 (默认优先读取 .agentengine.state)") -@click.option("--channel", type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), default=None, help="指定 channel") +@click.option( + "--channel", + type=click.Choice(OPENCLAW_CHANNELS, case_sensitive=False), + default=None, + help="指定 channel", +) @cli_output_option() -def channel_doctor(agent_ref: Optional[str], region: Optional[str], channel: Optional[str], output_mode: str | None): +def channel_doctor( + agent_ref: Optional[str], region: Optional[str], channel: Optional[str], output_mode: str | None +): """检查 channel 接入前置条件。""" _ = output_mode normalized_channel = str(channel).lower() if channel else None @@ -3028,20 +3220,28 @@ async def _run(): gateway = _build_gateway_client(resolved_region, detail) try: info = await gateway.build_access_info() - checks.append({"name": "dashboard_short_link", "ok": True, "dashboard_url": info.access_url}) + checks.append( + {"name": "dashboard_short_link", "ok": True, "dashboard_url": info.access_url} + ) await gateway.connect() methods = gateway.methods checks.append({"name": "cookie_ws_handshake", "ok": True, "methods": len(methods)}) snapshot = await gateway.channels_status(probe=False) config_snapshot = await gateway.config_get() - config = config_snapshot.get("config") if isinstance(config_snapshot.get("config"), dict) else {} + config = ( + config_snapshot.get("config") + if isinstance(config_snapshot.get("config"), dict) + else {} + ) except Exception as exc: checks.append({"name": "gateway_connectivity", "ok": False, "error": str(exc)}) finally: await gateway.close() channels_to_check = [normalized_channel] if normalized_channel else list(OPENCLAW_CHANNELS) - plugin_entries = ((config.get("plugins") or {}).get("entries") or {}) if isinstance(config, dict) else {} + plugin_entries = ( + ((config.get("plugins") or {}).get("entries") or {}) if isinstance(config, dict) else {} + ) for item in channels_to_check: selected_snapshot = _extract_channel_snapshot(snapshot, item) configured = _is_channel_configured(item, config=config, snapshot=selected_snapshot) @@ -3059,7 +3259,9 @@ async def _run(): name="weixin_status_snapshot", available=selected_snapshot is not None, configured=configured, - connect_required_message="首次连接前微信状态快照可能为空,执行 channel connect 后会自动补齐", + connect_required_message=( + "首次连接前微信状态快照可能为空," "执行 channel connect 后会自动补齐" + ), ) ) checks.append( @@ -3067,7 +3269,9 @@ async def _run(): name="weixin_qr_rpc", available="web.login.start" in methods and "web.login.wait" in methods, configured=configured, - connect_required_message="首次连接会先自动启用 bundled weixin plugin,然后再暴露扫码 RPC", + connect_required_message=( + "首次连接会先自动启用 bundled weixin plugin," "然后再暴露扫码 RPC" + ), connect_required_ok=False, required_methods=["web.login.start", "web.login.wait"], ) @@ -3085,7 +3289,10 @@ async def _run(): name="feishu_status_snapshot", available=selected_snapshot is not None, configured=configured, - connect_required_message="飞书尚未完成 connect/onboarding,首次接入前不会出现在 channel snapshot 中", + connect_required_message=( + "飞书尚未完成 connect/onboarding," + "首次接入前不会出现在 channel snapshot 中" + ), ) ) node_path = shutil.which("node") @@ -3111,7 +3318,9 @@ async def _run(): name="wps_xiezuo_status_snapshot", available=selected_snapshot is not None, configured=configured, - connect_required_message="WPS 协作尚未完成配置,首次 connect 前不会出现在 channel snapshot 中", + connect_required_message=( + "WPS 协作尚未完成配置," "首次 connect 前不会出现在 channel snapshot 中" + ), ) ) dep_check = _check_wps_xiezuo_local_deps() @@ -3134,15 +3343,20 @@ async def _run(): try: payload = asyncio.run(_run()) - _emit_data_payload("OpenClaw Channel Doctor", payload, subtitle=str(payload.get("name") or "-")) + _emit_data_payload( + "OpenClaw Channel Doctor", payload, subtitle=str(payload.get("name") or "-") + ) except Exception as e: - _abort_openclaw_error(e, context="channel doctor 执行失败", argv=["openclaw", "channel", "doctor"]) + _abort_openclaw_error( + e, context="channel doctor 执行失败", argv=["openclaw", "channel", "doctor"] + ) @openclaw.command("deploy", context_settings=CONTEXT_SETTINGS) @click.option("--name", "-n", default=None, help="OpenClaw 名称 (默认: openclaw-gateway)") @click.option( - "--region", "-r", + "--region", + "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="部署区域 (默认: cn-beijing-6)", @@ -3154,7 +3368,9 @@ async def _run(): help="安全预设: relaxed | strict | strictest (安全测试建议 strictest)", ) @click.option("--strict-mode", "security_profile", flag_value="strict", help="快捷开启严格模式") -@click.option("--strictest", "security_profile", flag_value="strictest", help="快捷开启最严格安全模式") +@click.option( + "--strictest", "security_profile", flag_value="strictest", help="快捷开启最严格安全模式" +) @click.option( "--image", default=None, @@ -3179,7 +3395,9 @@ async def _run(): help="额外透传自定义环境变量,格式 KEY=VALUE,可重复传入", ) @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( + "--storage-mount-path", default=None, help="PVC 挂载目录(默认: /home/node/.openclaw)" +) @click.option("--no-storage", is_flag=True, help="禁用默认 PVC 挂载") @network_options @dry_run_option("仅显示请求,不实际部署") @@ -3222,7 +3440,8 @@ def deploy( # 一键创建最严格实例(适合安全测试) agentengine openclaw deploy --security-profile strictest # 显式指定模型 - agentengine openclaw deploy --model-base-url https://api.example.com/v1 --model-api-key sk-xxx + agentengine openclaw deploy --model-base-url https://api.example.com/v1 \ + --model-api-key sk-xxx # 使用自定义镜像 agentengine openclaw deploy --image ghcr.io/my-org/openclaw:v2 # 透传业务自定义环境变量 @@ -3316,10 +3535,11 @@ async def _deploy_openclaw( dry_run: bool, ): """异步部署 OpenClaw""" - from ksadk.api import AgentEngineClient - from ksadk.deployment.state import load_state, save_state, clear_state from dotenv import dotenv_values + from ksadk.api import AgentEngineClient + from ksadk.deployment.state import clear_state, load_state, save_state + # 自动加载当前目录 .env(仅补充未导出的变量,不覆盖已导出的 shell 环境) project_dir = Path(".").resolve() env_file = project_dir / ".env" @@ -3377,13 +3597,15 @@ async def _deploy_openclaw( env_vars.update(custom_env_vars) 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("OPENCLAW_MODEL_BASE_URL", "OPENAI_BASE_URL", "OPENAI_API_BASE", "LLM_API_BASE", "MODEL_API_BASE") + catalog_api_base = model_base_url or _resolve_env( + "OPENCLAW_MODEL_BASE_URL", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", + "LLM_API_BASE", + "MODEL_API_BASE", ) - catalog_api_key = ( - model_api_key - or _resolve_env("OPENCLAW_MODEL_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "MODEL_API_KEY") + catalog_api_key = model_api_key or _resolve_env( + "OPENCLAW_MODEL_API_KEY", "OPENAI_API_KEY", "LLM_API_KEY", "MODEL_API_KEY" ) provider_catalog = await fetch_provider_model_catalog( api_base=catalog_api_base, @@ -3391,7 +3613,9 @@ async def _deploy_openclaw( ) selected_catalog = _filter_openclaw_provider_catalog(env_vars, provider_catalog) if _apply_openclaw_provider_model_catalog(env_vars, selected_catalog): - print_info(f"已从模型服务 /v1/models 同步 OpenClaw 模型元数据: {len(selected_catalog)} 个") + print_info( + f"已从模型服务 /v1/models 同步 OpenClaw 模型元数据: {len(selected_catalog)} 个" + ) memory_config = _build_openclaw_memory_config( memory_system=memory_system, mem0_instance_id=mem0_instance_id, @@ -3445,7 +3669,9 @@ async def _deploy_openclaw( if storage_config: request_data["storage"] = storage_config if existing_agent_id and include_storage_on_update and no_storage: - print_warn("更新已有 OpenClaw 时 `--no-storage` 不会删除服务端既有挂盘配置;默认保留已有配置。") + print_warn( + "更新已有 OpenClaw 时 `--no-storage` 不会删除服务端既有挂盘配置;默认保留已有配置。" + ) network_payload = build_network_payload( enable_public_access=enable_public_access, enable_vpc_access=enable_vpc_access, @@ -3456,7 +3682,7 @@ async def _deploy_openclaw( region=region, dry_run=dry_run, ) - # create 默认开公网(network_payload 未显式 enable_public_access 时补 True);update 分支用原始 network_payload(None=保留现有配置) + # Create 默认开公网;update 使用原始 payload,None 表示保留现有配置。 create_network_payload = dict(network_payload) if network_payload is not None else {} if "enable_public_access" not in create_network_payload: create_network_payload["enable_public_access"] = True @@ -3477,12 +3703,15 @@ async def _deploy_openclaw( request_data["image_credential"] = image_credential elif kcr_password and not kcr_username and registry_kind != "personal_kcr": print_warn( - f"检测到 KCR_PASSWORD 但缺少 KCR_USERNAME,已忽略{registry_kind_label(registry_kind)}镜像凭证;" + "检测到 KCR_PASSWORD 但缺少 KCR_USERNAME,已忽略" + f"{registry_kind_label(registry_kind)}镜像凭证;" "企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD" ) elif "/agentengine-public/" not in image_ref: if registry_kind == "personal_kcr": - print_warn("未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取") + print_warn( + "未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取" + ) else: print_warn( f"未配置{registry_kind_label(registry_kind)}镜像凭证 " @@ -3538,12 +3767,13 @@ async def _deploy_openclaw( except Exception as update_err: err_msg = str(update_err) not_found = ( - "code: 404" in err_msg.lower() - or "agent not found" in err_msg.lower() + "code: 404" in err_msg.lower() or "agent not found" in err_msg.lower() ) if not not_found: raise - print_warn(f"本地状态失效 ({existing_agent_id}),将自动回退为新建: {update_err}") + print_warn( + f"本地状态失效 ({existing_agent_id}),将自动回退为新建: {update_err}" + ) cleared = clear_state(project_dir, key=existing_agent_id) if cleared: print_info("已清理失效的 .agentengine.state") @@ -3568,10 +3798,12 @@ async def _deploy_openclaw( attempts=12, interval_seconds=5, include_api_key=True, - detail_fetcher=lambda agent_ref, include_api_key: _get_openclaw_detail_with_client( - client, - agent_ref, - include_api_key=include_api_key, + detail_fetcher=lambda agent_ref, include_api_key: ( + _get_openclaw_detail_with_client( + client, + agent_ref, + include_api_key=include_api_key, + ) ), suppress_transient_not_found_log=True, ) @@ -3583,8 +3815,7 @@ async def _deploy_openclaw( else: print_warn("实例创建中,稍后使用 'agentengine openclaw list' 查看") elif agent_id and ( - not str(endpoint or "").strip() - or not str(api_key or "").strip() + not str(endpoint or "").strip() or not str(api_key or "").strip() ): latest = await get_latest_agent_access( client, @@ -3594,10 +3825,12 @@ async def _deploy_openclaw( initial_delay_seconds=2, require_complete_access=True, include_api_key=True, - detail_fetcher=lambda agent_ref, include_api_key: _get_openclaw_detail_with_client( - client, - agent_ref, - include_api_key=include_api_key, + detail_fetcher=lambda agent_ref, include_api_key: ( + _get_openclaw_detail_with_client( + client, + agent_ref, + include_api_key=include_api_key, + ) ), suppress_transient_not_found_log=True, ) @@ -3627,11 +3860,13 @@ async def _deploy_openclaw( state_payload["openclaw_gateway_token"] = state_gateway_token save_state(project_dir, state_payload) - # 仅在更新已有实例时回读一次状态;新建时底层可能尚未落库,立即按 ID 查询会产生误导性报错。 + # 更新已有实例才回读;新建后立即查询可能早于底层落库。 if updated_existing_agent and agent_id: try: latest = await client.get_agent(agent_id=agent_id, include_api_key=False) - latest_status = str(((latest.get("basic") or {}).get("status") or "")).upper() or None + latest_status = ( + str(((latest.get("basic") or {}).get("status") or "")).upper() or None + ) except Exception: latest_status = None @@ -3672,7 +3907,9 @@ def list_openclaws(region: str, page: int, size: int, dry_run: bool, output_mode async def _list(): async with AgentEngineClient(region=region, dry_run=dry_run) as client: - resp = await client.list_agents(region=region, framework="openclaw", page=page, page_size=size) + resp = await client.list_agents( + region=region, framework="openclaw", page=page, page_size=size + ) agents = resp.get("agents", []) or [] total = int(resp.get("total") or len(agents)) rows = [] @@ -3765,7 +4002,9 @@ async def _get(): agent = await client.get_agent(name=agent_ref) if not agent: - raise resolution_error(f"未找到 OpenClaw: {agent_ref}", hints=["agentengine openclaw list"]) + raise resolution_error( + f"未找到 OpenClaw: {agent_ref}", hints=["agentengine openclaw list"] + ) detail = _flatten_agent_detail(agent) status_val = detail.get("status", "UNKNOWN") @@ -3781,7 +4020,11 @@ async def _get(): ("框架", str(detail.get("framework") or "-"), None), ("区域", str(detail.get("region") or region), None), ("Endpoint", str(detail.get("endpoint") or "N/A"), "#58a6ff"), - ("Langfuse", str(detail.get("langfuse_url") or "-"), "#58a6ff" if detail.get("langfuse_url") else None), + ( + "Langfuse", + str(detail.get("langfuse_url") or "-"), + "#58a6ff" if detail.get("langfuse_url") else None, + ), ("镜像", str(detail.get("artifact_path") or "-"), None), ("创建时间", created_at_display, None), ("更新时间", updated_at_display, None), @@ -3844,6 +4087,7 @@ async def _delete(): # 清理本地状态 from ksadk.deployment.state import clear_state + try: removed = clear_state(Path("."), key=agent_ref) if removed: @@ -3866,7 +4110,7 @@ async def _delete(): "failed": failed_refs, } - dry_run_kwargs = {"dry_run": dry_run} + dry_run_kwargs: dict[str, Any] = {"dry_run": dry_run} if is_json_output(): dry_run_kwargs.update( dry_run_resource="openclaw", @@ -3904,7 +4148,13 @@ async def _delete(): @confirm_options() @dry_run_option() @cli_output_option() -def delete(agent_refs: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None): +def delete( + agent_refs: tuple[str, ...], + region: str, + assume_yes: bool, + dry_run: bool, + output_mode: str | None, +): """删除 OpenClaw 实例。""" _ = output_mode _delete_impl(agent_refs=agent_refs, region=region, assume_yes=assume_yes, dry_run=dry_run) @@ -3916,7 +4166,13 @@ def delete(agent_refs: tuple[str, ...], region: str, assume_yes: bool, dry_run: @confirm_options() @dry_run_option() @cli_output_option() -def destroy(agent_refs: tuple[str, ...], region: str, assume_yes: bool, dry_run: bool, output_mode: str | None): +def destroy( + agent_refs: tuple[str, ...], + region: str, + assume_yes: bool, + dry_run: bool, + output_mode: str | None, +): """删除 OpenClaw 实例。""" _ = output_mode _delete_impl(agent_refs=agent_refs, region=region, assume_yes=assume_yes, dry_run=dry_run) diff --git a/ksadk/cli/cmd_replay.py b/ksadk/cli/cmd_replay.py new file mode 100644 index 00000000..93bc8e04 --- /dev/null +++ b/ksadk/cli/cmd_replay.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""ksadk replay — 基于 RuntimeEvent store + session 级订阅的历史回放 (goal-16)。 + +CLI 是消费 RuntimeAdapter/RuntimeEvent 的薄壳(不含 runtime 逻辑):replay 复用 goal-10 +``RuntimeEventStore`` 与 goal-12 共享 parser/replay,把某 session 的 RuntimeEvent 历史 +回放为确定性 transcript(text/reasoning/tool/artifact/run + a2ui/a2a extras)。 + +注:回放的是经 RuntimeEvent 持久化的新事件模型(带 runtime marker);legacy SessionEvent +(旧 assistant_message 等)不在本模型内,不回放。 +""" + +from __future__ import annotations + +import asyncio +import json + +import click + +from ksadk.cli.resource_common import CONTEXT_SETTINGS +from ksadk.events.replay import replay_transcript +from ksadk.events.store import RuntimeEventStore + +_HELP = dict(help_option_names=["-h", "--help"]) + + +@click.command( + "replay", context_settings=CONTEXT_SETTINGS, help="回放某 session 的 RuntimeEvent 历史" +) +@click.argument("session_id") +@click.option( + "--after-seq-id", default=0, show_default=True, type=int, help="从该 seq cursor 之后回放" +) +@click.option("--before-seq-id", default=None, type=int, help="回放上界(不含该 seq)") +@click.option( + "--format", "fmt", type=click.Choice(["json", "text"]), default="text", show_default=True +) +def replay(session_id: str, after_seq_id: int, before_seq_id: int | None, fmt: str): + """回放 session 的 RuntimeEvent 历史(text/reasoning/tool/artifact/run)。""" + asyncio.run(_run(session_id, after_seq_id=after_seq_id, before_seq_id=before_seq_id, fmt=fmt)) + + +async def _run(session_id: str, *, after_seq_id: int, before_seq_id: int | None, fmt: str) -> None: + from ksadk.sessions import resolve_session_service + + store = RuntimeEventStore(resolve_session_service()) + parser = await replay_transcript( + store, session_id, after_seq_id=after_seq_id, before_seq_id=before_seq_id + ) + transcript = parser.transcript() + if fmt == "json": + click.echo(json.dumps(transcript, ensure_ascii=False, sort_keys=True)) + return + + items = transcript["items"] + if not items and not transcript["extras"]: + click.echo(f"session {session_id} 无 RuntimeEvent 历史(或该 session 未经新事件模型持久化)") + return + for item in items: + kind = item.get("kind") + if kind in ("text", "reasoning"): + marker = "▍final" if item.get("final") else "▍delta" + click.echo(f"[{kind}/{item.get('phase')}] {marker} {item.get('text')}") + elif kind == "tool_call": + done = "✓" if item.get("done") else "…" + click.echo(f"[tool] {done} {item.get('name')} ({item.get('call_id')})") + elif kind == "artifact": + click.echo(f"[artifact] {item.get('name')} v{item.get('version')}") + for inv, status in transcript["run_status"].items(): + click.echo(f"[run] {inv}: {status}") + for extra in transcript["extras"]: + click.echo(f"[{extra['event_type']}] {json.dumps(extra['payload'], ensure_ascii=False)}") diff --git a/ksadk/cli/cmd_run.py b/ksadk/cli/cmd_run.py index 4e3cfc17..d962c0f7 100644 --- a/ksadk/cli/cmd_run.py +++ b/ksadk/cli/cmd_run.py @@ -5,12 +5,13 @@ 对于 LangChain/LangGraph/DeepAgents 项目,使用自己的实现 """ -import click -import asyncio +import os import subprocess import sys -import os from pathlib import Path + +import click + 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.ui import ( @@ -38,16 +39,28 @@ is_flag=True, help="兼容参数;TUI 已默认使用 inline viewport 并保留终端 scrollback", ) -def run(agent_dir: str, port: int, interactive: bool, no_trace: bool, model: str, show_thinking: bool, no_stream: bool, no_alt_screen: bool): +def run( + agent_dir: str, + port: int, + interactive: bool, + no_trace: bool, + model: str, + show_thinking: bool, + no_stream: bool, + no_alt_screen: bool, +): """运行 Agent (支持 LangChain / LangGraph / DeepAgents / ADK) AGENT_DIR: Agent 项目目录 (默认: 当前目录) """ ensure_json_output_supported( "agentengine run", - suggestion="请改用 `agentengine agent status --output json` 或 `agentengine build --output json` 获取结构化信息。", + suggestion=( + "请改用 `agentengine agent status --output json` 或 " + "`agentengine build --output json` 获取结构化信息。" + ), ) - from ksadk.detection import FrameworkDetector, FrameworkType + from ksadk.detection import FrameworkDetector agent_path = Path(agent_dir).resolve() command_args = ["run", str(agent_path), "--port", str(port)] @@ -91,7 +104,7 @@ def run(agent_dir: str, port: int, interactive: bool, no_trace: bool, model: str "langchain": "LangChain", "langgraph": "LangGraph", "deepagents": "DeepAgents", - "unknown": "Unknown" + "unknown": "Unknown", } framework_name = framework_map.get(result.type.value, result.type.value) @@ -99,10 +112,16 @@ def run(agent_dir: str, port: int, interactive: bool, no_trace: bool, model: str print_kv("Agent 名称", str(result.name)) print_kv("入口点", str(result.entry_point)) + from ksadk.cli.cmd_web import configure_local_runtime_persistence + + configure_local_runtime_persistence(agent_path, result.type.value) + # 2. 根据框架类型选择处理方式 # 所有框架统一使用 _run_custom() 以支持 Langfuse 自动插桩 # (Langfuse instrumentation 需要在同一进程内生效) - _run_custom(result, agent_path, port, interactive, no_trace, show_thinking, no_stream, no_alt_screen) + _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"): @@ -178,9 +197,10 @@ def _run_custom( # 初始化 Tracing if not no_trace: try: - from ksadk.tracing import setup_tracing import os + from ksadk.tracing import setup_tracing + has_langfuse = bool(os.getenv("LANGFUSE_PUBLIC_KEY")) has_otlp = bool( os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") @@ -203,7 +223,9 @@ def _run_custom( if has_otlp: print_info("Tracing: Enabled (InMemory + OTLP HTTP)") elif has_langfuse: - print_info(f"Tracing: Enabled (InMemory + Langfuse, CallbackOnly={use_callback_only})") + print_info( + f"Tracing: Enabled (InMemory + Langfuse, CallbackOnly={use_callback_only})" + ) else: print_info("Tracing: Enabled") except Exception as e: @@ -225,6 +247,7 @@ def _run_custom( if interactive: # TUI 交互模式 from ksadk.tui.loop import run_tui + run_tui( runner, show_thinking=show_thinking, diff --git a/ksadk/cli/cmd_status.py b/ksadk/cli/cmd_status.py index e1e901ef..2263f6db 100644 --- a/ksadk/cli/cmd_status.py +++ b/ksadk/cli/cmd_status.py @@ -4,15 +4,17 @@ 支持 watch 模式实时刷新 """ -import click import asyncio import time -from pathlib import Path from datetime import datetime +from pathlib import Path from typing import Sequence + +import click + from ksadk.api.client import DryRunExit from ksadk.cli.agent_ref import merge_agent_inputs, resolve_agent_ref -from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run +from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run from ksadk.cli.error_utils import print_exception, resolution_error, usage_error from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, @@ -24,14 +26,10 @@ from ksadk.cli.ui import ( get_console, is_json_output, - print_error, print_info, - print_kv, - print_title, - print_warn, + replica_rich_style, status_click_color, status_rich_style, - replica_rich_style, summary_panel, ) @@ -55,7 +53,9 @@ @click.option("--watch", "-w", is_flag=True, help="Watch 模式,持续刷新") @click.option("--interval", "-i", default=2, help="Watch 刷新间隔 (秒)") @click.option("--region", "-r", default="cn-beijing-6", envvar="KSYUN_REGION", help="区域") -@click.option("--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)") +@click.option( + "--account-id", envvar="KSYUN_ACCOUNT_ID", help="金山云账号 ID(可选;未设置时从 AK/SK 反查)" +) @dry_run_option() def status( agent_ref: str, @@ -125,9 +125,12 @@ def run_status_command( if not resolved: raise resolution_error( "请指定 Agent(--agent 或位置参数),或在当前目录提供可解析的本地配置", - hints=["自动解析顺序: .agentengine.state -> agentengine.yaml/ksadk.yaml", "请先执行 `agentengine agent list`。"], + hints=[ + "自动解析顺序: .agentengine.state -> agentengine.yaml/ksadk.yaml", + "请先执行 `agentengine agent list`。", + ], ) - agent = resolved.value + agent = str(resolved.value) if resolved.source != "cli": print_info(f"未显式指定 Agent,使用 {resolved.source_text}: {agent}") @@ -136,23 +139,34 @@ def run_status_command( if watch and dry_run: raise usage_error("Watch 模式不支持 dry-run,请去掉 --watch 或取消 dry-run。") if watch and is_json_output(): - raise usage_error("Watch 模式不支持 `--output json`。", hints=["请去掉 `--watch` 或改用 `agentengine agent status --output json`。"]) + raise usage_error( + "Watch 模式不支持 `--output json`。", + hints=["请去掉 `--watch` 或改用 `agentengine agent status --output json`。"], + ) # Dry Run 模式由 AgentEngineClient 处理 # 只要传入 dry_run=True,底层 client 会抛出 DryRunExit 异常 + resolved_account_id = account_id or "" if show_all: run_async_with_dry_run( - _list_all_agents(region, account_id, dry_run, page=page, size=size, framework=framework), + _list_all_agents( + region, + resolved_account_id, + dry_run, + page=page, + size=size, + framework=framework, + ), dry_run=dry_run, dry_run_resource="agent", dry_run_action="list", ) elif watch: - _watch_status(agent, region, account_id, interval) + _watch_status(str(agent), region, resolved_account_id, interval) else: run_async_with_dry_run( - _show_agent_status(agent, region, account_id, dry_run), + _show_agent_status(str(agent), region, resolved_account_id, dry_run), dry_run=dry_run, dry_run_resource="agent", dry_run_action="status", @@ -170,7 +184,7 @@ def _watch_status(agent: str, region: str, account_id: str, interval: int): click.clear() # 显示标题 - click.secho(f"Agent Status Monitor", fg="blue", bold=True) + click.secho("Agent Status Monitor", fg="blue", bold=True) click.echo(f" Agent: {agent}") click.echo(f" Region: {region}") click.echo(f" 更新时间: {datetime.now().strftime('%H:%M:%S')}") @@ -205,7 +219,9 @@ async def _show_agent_status( # 先按 ID 查询,失败后再按名称查询,避免依赖字符串前缀判断。 result = await _get_agent_runtime(agent, region, account_id, dry_run, is_name=False) if result.get("status") == "Error": - result_by_name = await _get_agent_runtime(agent, region, account_id, dry_run, is_name=True) + result_by_name = await _get_agent_runtime( + agent, region, account_id, dry_run, is_name=True + ) if result_by_name.get("status") != "Error": result = result_by_name @@ -219,14 +235,14 @@ async def _show_agent_status( langfuse_url = result.get("langfuseTraceUrl") from dateutil import parser - created_at = result.get('createdAt') + created_at = result.get("createdAt") if created_at: dt = parser.parse(created_at) created_at = dt.astimezone().isoformat() else: created_at = "-" - updated_at = result.get('updatedAt') + updated_at = result.get("updatedAt") if updated_at: dt = parser.parse(updated_at) updated_at = dt.astimezone().isoformat() @@ -378,8 +394,8 @@ async def _list_all_agents( rows = [] items = [] for r in results: - agent_id = (r.get("agentRuntimeId") or "N/A") - name = (r.get("agentRuntimeName") or "N/A") + agent_id = r.get("agentRuntimeId") or "N/A" + name = r.get("agentRuntimeName") or "N/A" status_val = r.get("status", "Unknown") ready = int(r.get("readyReplicas", 0) or 0) replicas = int(r.get("replicas", 0) or 0) @@ -419,8 +435,20 @@ async def _list_all_agents( columns=( {"header": "ID", "key": "id", "style": "#58a6ff", "no_wrap": True, "min_width": 24}, {"header": "名称", "key": "name", "style": "#c9d1d9", "min_width": 20}, - {"header": "状态", "key": "status", "no_wrap": True, "justify": "center", "min_width": 10}, - {"header": "副本", "key": "replicas_display", "no_wrap": True, "justify": "center", "min_width": 8}, + { + "header": "状态", + "key": "status", + "no_wrap": True, + "justify": "center", + "min_width": 10, + }, + { + "header": "副本", + "key": "replicas_display", + "no_wrap": True, + "justify": "center", + "min_width": 8, + }, {"header": "Endpoint", "key": "endpoint", "style": "#8b949e", "overflow": "ellipsis"}, ), rows=rows, @@ -446,7 +474,9 @@ async def _list_all_agents( ], ) if not is_json_output(): - console.print(summary_panel(total=len(results), healthy=running, attention=unhealthy, noun="Agent")) + console.print( + summary_panel(total=len(results), healthy=running, attention=unhealthy, noun="Agent") + ) def _get_status_color(status: str) -> str: @@ -454,19 +484,24 @@ def _get_status_color(status: str) -> str: return status_click_color(status) -async def _get_agent_runtime(agent: str, region: str, account_id: str, dry_run: bool = False, is_name: bool = False) -> dict: +async def _get_agent_runtime( + agent: str, region: str, account_id: str, dry_run: bool = False, is_name: bool = False +) -> dict: """获取 Agent 运行时状态 调用 AgentEngine Server API """ from ksadk.api import AgentEngineClient + try: extra_headers = {} # 传递 Account ID if account_id: extra_headers["X-Ksc-Account-Id"] = account_id - async with AgentEngineClient(region=region, dry_run=dry_run, extra_headers=extra_headers) as client: + async with AgentEngineClient( + region=region, dry_run=dry_run, extra_headers=extra_headers + ) as client: if is_name: response = await client.get_agent(name=agent) else: @@ -484,10 +519,21 @@ async def _get_agent_runtime(agent: str, region: str, account_id: str, dry_run: "description": basic.get("description", "") or response.get("description", ""), "status": basic.get("status", "") or response.get("status", "Unknown"), "phase": basic.get("phase", "") or response.get("phase", ""), - "replicas": basic.get("replicas") if basic.get("replicas") is not None else response.get("replicas", deploy.get("scaling", {}).get("min_replicas", 1)), - "readyReplicas": basic.get("ready_replicas") if basic.get("ready_replicas") is not None else response.get("ready_replicas", 0), - "endpoint": quick.get("public_endpoint") or quick.get("private_endpoint") or response.get("endpoint", ""), - "langfuseTraceUrl": adv.get("observability_url", "") or response.get("langfuse_trace_url", ""), + "replicas": ( + basic.get("replicas") + if basic.get("replicas") is not None + else response.get("replicas", deploy.get("scaling", {}).get("min_replicas", 1)) + ), + "readyReplicas": ( + basic.get("ready_replicas") + if basic.get("ready_replicas") is not None + else response.get("ready_replicas", 0) + ), + "endpoint": quick.get("public_endpoint") + or quick.get("private_endpoint") + or response.get("endpoint", ""), + "langfuseTraceUrl": adv.get("observability_url", "") + or response.get("langfuse_trace_url", ""), "createdAt": basic.get("created_at", "") or response.get("created_at", ""), "updatedAt": basic.get("updated_at", "") or response.get("updated_at", ""), "message": basic.get("message", "") or response.get("message", ""), @@ -517,13 +563,16 @@ async def _list_agent_runtimes( 调用 AgentEngine Server API """ from ksadk.api import AgentEngineClient + try: extra_headers = {} # 传递 Account ID if account_id: extra_headers["X-Ksc-Account-Id"] = account_id - async with AgentEngineClient(region=region, dry_run=dry_run, extra_headers=extra_headers) as client: + async with AgentEngineClient( + region=region, dry_run=dry_run, extra_headers=extra_headers + ) as client: response = await client.list_agents( region=region, framework=framework, diff --git a/ksadk/cli/cmd_version.py b/ksadk/cli/cmd_version.py index 17c597f6..aa921ca9 100644 --- a/ksadk/cli/cmd_version.py +++ b/ksadk/cli/cmd_version.py @@ -1,14 +1,15 @@ """agentengine version - Agent 版本资源管理。""" import os -import click +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Optional -from datetime import datetime, timedelta, timezone + +import click from ksadk.api.client import DryRunExit from ksadk.cli.agent_ref import merge_agent_inputs, resolve_agent_ref -from ksadk.cli.dry_run import dry_run_option, run_async_with_dry_run, effective_dry_run +from ksadk.cli.dry_run import dry_run_option, effective_dry_run, run_async_with_dry_run from ksadk.cli.error_utils import abort_with_cli_error, resolution_error from ksadk.cli.resource_common import ( CONTEXT_SETTINGS, @@ -23,7 +24,8 @@ render_descriptor_list, render_descriptor_status, ) -from ksadk.cli.ui import get_console, output_option as cli_output_option, status_rich_style +from ksadk.cli.ui import get_console, status_rich_style +from ksadk.cli.ui import output_option as cli_output_option console = get_console() @@ -94,8 +96,9 @@ def _extract_agent_id(agent: dict) -> Optional[str]: if isinstance(basic, dict): agent_id = basic.get("agent_id") if agent_id: - return agent_id - return agent.get("agent_id") or agent.get("id") + return str(agent_id) + fallback = agent.get("agent_id") or agent.get("id") + return str(fallback) if fallback else None async def _resolve_agent_id(agent_ref: str, client) -> Optional[str]: @@ -147,9 +150,7 @@ async def _resolve_target_agent_id( include_project_config=True, ) if not resolved: - raise ValueError( - "请指定 Agent(--agent 或位置参数),或在当前目录提供可解析的本地配置" - ) + raise ValueError("请指定 Agent(--agent 或位置参数),或在当前目录提供可解析的本地配置") if resolved.source != "cli": console.print( @@ -162,7 +163,9 @@ async def _resolve_target_agent_id( return agent_id -@click.group("version", context_settings=CONTEXT_SETTINGS, help=build_resource_group_help(VERSION_RESOURCE)) +@click.group( + "version", context_settings=CONTEXT_SETTINGS, help=build_resource_group_help(VERSION_RESOURCE) +) def version(): pass @@ -345,7 +348,9 @@ def release_version( dry_run = effective_dry_run(dry_run) try: run_async_with_dry_run( - _release_version_async(agent_ref, agent_option, name, region, tag, description, dry_run), + _release_version_async( + agent_ref, agent_option, name, region, tag, description, dry_run + ), dry_run=dry_run, dry_run_resource="version", dry_run_action="release", @@ -437,7 +442,9 @@ def rollback_version( dry_run = effective_dry_run(dry_run) try: run_async_with_dry_run( - _rollback_version_async(agent_ref, agent_option, name, region, target, assume_yes, dry_run), + _rollback_version_async( + agent_ref, agent_option, name, region, target, assume_yes, dry_run + ), dry_run=dry_run, dry_run_resource="version", dry_run_action="rollback", @@ -496,7 +503,7 @@ async def _rollback_version_async( ks3_secret_key=secret_key, ) - fields = [ + fields: list[tuple[str, str, str | None]] = [ ("当前版本", str(result.get("current_tag") or "-"), "#58a6ff"), ] if result.get("message"): diff --git a/ksadk/cli/cmd_web.py b/ksadk/cli/cmd_web.py index f3a7caf7..be7a97d6 100644 --- a/ksadk/cli/cmd_web.py +++ b/ksadk/cli/cmd_web.py @@ -1,10 +1,13 @@ """ksadk web - 启动统一本地 Web UI。""" -import click +import asyncio +import os import webbrowser from pathlib import Path -import os + +import click 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.ui import ( @@ -13,12 +16,12 @@ print_kv, print_success, print_title, + print_warn, ) 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 = ( "KSADK_STM_BACKEND", @@ -179,6 +182,47 @@ def _default_project_stm_if_unset( ) +def configure_local_runtime_persistence( + agent_path: Path, + framework: str, + *, + explicit_session_env_names: set[str] | None = None, + explicit_checkpoint_env_names: set[str] | None = None, + explicit_local_ui_env_names: set[str] | None = None, +) -> None: + """Bind locally-run agent state to its project unless the shell overrides it.""" + + project_dotenv = _project_dotenv_values(agent_path) + if explicit_session_env_names is None: + explicit_session_env_names = _explicit_env_names_excluding_project_dotenv( + (*_STM_ENV_NAMES, *_SESSION_ENV_NAMES), + project_dotenv, + ) + if explicit_checkpoint_env_names is None: + explicit_checkpoint_env_names = _explicit_env_names_excluding_project_dotenv( + _CHECKPOINT_ENV_NAMES, + project_dotenv, + ) + if explicit_local_ui_env_names is None: + explicit_local_ui_env_names = _explicit_env_names_excluding_project_dotenv( + _LOCAL_UI_ENV_NAMES, + project_dotenv, + ) + + os.environ["KSADK_PROJECT_DIR"] = str(agent_path) + local_ui_dir = str(agent_path / ".agentengine" / "ui") + if "AGENTENGINE_UI_DIR" not in explicit_local_ui_env_names: + os.environ["AGENTENGINE_UI_DIR"] = local_ui_dir + else: + os.environ.setdefault("AGENTENGINE_UI_DIR", local_ui_dir) + _default_project_stm_if_unset( + framework, + agent_path, + explicit_session_env_names=explicit_session_env_names, + explicit_checkpoint_env_names=explicit_checkpoint_env_names, + ) + + @click.command(context_settings=dict(help_option_names=["-h", "--help"])) @click.argument("agent_dir", default=".", type=click.Path(exists=True)) @click.option("--port", "-p", default=8080, help="Web UI 端口") @@ -197,13 +241,18 @@ def web(agent_dir: str, port: int, model: str, no_open: bool): """ ensure_json_output_supported( "agentengine web", - suggestion="请改用 `agentengine dashboard open` 或 `agentengine agent status --output json`。", + suggestion=( + "请改用 `agentengine dashboard open` 或 " "`agentengine agent status --output json`。" + ), ) agent_path = Path(agent_dir).resolve() command_args = ["web", str(agent_path), "--port", str(port)] if model: command_args.extend(["--model", model]) + if no_open: + # re-exec 进项目 venv 时透传 --no-open,否则子进程仍会打开浏览器 + command_args.append("--no-open") reexec_with_project_venv_if_needed(agent_path, command_args) project_dotenv = _project_dotenv_values(agent_path) explicit_session_env_names = _explicit_env_names_excluding_project_dotenv( @@ -238,28 +287,55 @@ def web(agent_dir: str, port: int, model: str, no_open: bool): print_error("未检测到支持的框架") raise SystemExit(1) + if result.type.value == "codex": + raw_config = getattr(result, "raw_config", None) or {} + if str(raw_config.get("artifact_type") or "").strip().lower() == "managedruntime": + from ksadk.managed_runtime import ( + ManagedRuntimeError, + resolve_local_managed_runtime, + ) + + try: + resolved_runtime = asyncio.run( + resolve_local_managed_runtime( + raw_config, + region=os.getenv("KSYUN_REGION", "cn-beijing-6"), + ) + ) + except ManagedRuntimeError as exc: + print_error(str(exc)) + raise SystemExit(1) from exc + runtime_config = raw_config.setdefault("runtime", {}) + if isinstance(runtime_config, dict): + runtime_config["version"] = resolved_runtime.version + print_kv( + "Runtime", + f"{resolved_runtime.name}@{resolved_runtime.version}", + value_style="#58a6ff", + ) + if resolved_runtime.source == "installed-unlocked": + print_warn( + "离线使用本机已安装 Runtime;云端构建前请显式锁定版本" + "或连接 AgentEngine 获取默认版本" + ) + # Map framework types to display names framework_map = { "adk": "ADK", "langchain": "LangChain", "langgraph": "LangGraph", "deepagents": "DeepAgents", + "codex": "Codex", } display_name = framework_map.get(result.type.value, result.name) print_kv("框架", display_name, value_style="#2da44e") - # 本地 UI 的持久化目录与项目根绑定 - os.environ["KSADK_PROJECT_DIR"] = str(agent_path) - local_ui_dir = str(agent_path / ".agentengine" / "ui") - if "AGENTENGINE_UI_DIR" not in explicit_local_ui_env_names: - os.environ["AGENTENGINE_UI_DIR"] = local_ui_dir - else: - os.environ.setdefault("AGENTENGINE_UI_DIR", local_ui_dir) - _default_project_stm_if_unset( - result.type.value, + configure_local_runtime_persistence( agent_path, + result.type.value, explicit_session_env_names=explicit_session_env_names, explicit_checkpoint_env_names=explicit_checkpoint_env_names, + explicit_local_ui_env_names=explicit_local_ui_env_names, ) launch_path = _configure_custom_ui_env(agent_path) diff --git a/ksadk/cli/deploy_utils.py b/ksadk/cli/deploy_utils.py index 1ba77d8d..24e80b4c 100644 --- a/ksadk/cli/deploy_utils.py +++ b/ksadk/cli/deploy_utils.py @@ -1,28 +1,28 @@ import os + import click + from ksadk.api import AgentEngineClient + async def auto_release_version(agent_id: str, region: str, deploy_name: str): """部署成功后自动创建版本快照""" - from datetime import datetime - + access_key = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") secret_key = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") - + client = AgentEngineClient( access_key=access_key, secret_key=secret_key, region=region, ) - + try: description = "部署后自动发布" result = await client.release_version( - agent_id=agent_id, - tag=None, # 自动生成 - description=description + agent_id=agent_id, tag=None, description=description # 自动生成 ) - + click.echo("") click.secho(f"✓ 版本快照已创建: {result.get('tag')}", fg="green") except Exception as e: @@ -34,53 +34,53 @@ async def auto_release_version(agent_id: str, region: str, deploy_name: str): async def auto_rollback_to_previous(agent_id: str, region: str): """部署失败时自动回滚到上一版本""" - + access_key = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") secret_key = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") - + client = AgentEngineClient( access_key=access_key, secret_key=secret_key, region=region, ) - + try: # 获取版本列表,找到最近的历史版本 versions_result = await client.list_versions(agent_id, page=1, size=5) versions = versions_result.get("versions", []) - + # 找到需要回滚的目标版本 (统一 snake_case) target_version = None for v in versions: if (v.get("status") or "").lower() == "current": target_version = v break - + # 如果没有 Current,则找最新的 Historical if not target_version: for v in versions: if (v.get("status") or "").lower() == "historical": target_version = v break - + if not target_version: click.secho("⚠ 无可用稳定版本,跳过自动回滚", fg="yellow") return - + target_tag = target_version.get("tag") click.echo("") click.secho(f"⏪ 正在自动回滚到版本: {target_tag}...", fg="yellow") - + # 执行回滚 - result = await client.rollback_version( + await client.rollback_version( agent_id=agent_id, target_tag=target_tag, ks3_access_key=access_key, - ks3_secret_key=secret_key + ks3_secret_key=secret_key, ) - + click.secho(f"✓ 已回滚到版本: {target_tag}", fg="green") - + except Exception as e: click.secho(f"⚠ 自动回滚失败: {e}", fg="red") finally: diff --git a/ksadk/cli/dry_run.py b/ksadk/cli/dry_run.py index 8c148a82..a8325b6d 100644 --- a/ksadk/cli/dry_run.py +++ b/ksadk/cli/dry_run.py @@ -5,7 +5,7 @@ import asyncio import json import os -from typing import Any, Awaitable, Callable, Optional, TypeVar +from typing import Any, Callable, Coroutine, Optional, TypeVar import click from click.core import ParameterSource @@ -172,7 +172,7 @@ def sanitize_dry_run_request(payload: dict[str, Any] | None) -> dict[str, Any]: def run_async_with_dry_run( - coro: Awaitable[_T], + coro: Coroutine[Any, Any, _T], *, dry_run: bool, done_message: str = _DEFAULT_DONE_MSG, @@ -200,7 +200,9 @@ def run_async_with_dry_run( ) elif safe_payload: click.echo("=" * 60) - click.echo(f"Dry Run Mode: {safe_payload.get('method', 'REQUEST')} {safe_payload.get('url', '')}") + method = safe_payload.get("method", "REQUEST") + url = safe_payload.get("url", "") + click.echo(f"Dry Run Mode: {method} {url}") click.echo("=" * 60) click.echo(f"Headers: {safe_payload.get('headers')}") if safe_payload.get("body") is not None: diff --git a/ksadk/cli/error_utils.py b/ksadk/cli/error_utils.py index 1b4b98ca..09d407d5 100644 --- a/ksadk/cli/error_utils.py +++ b/ksadk/cli/error_utils.py @@ -2,10 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass, field import os import re import sys +from dataclasses import dataclass, field from typing import Any, Optional, Sequence, Tuple import click @@ -128,9 +128,16 @@ def _looks_like_invalid_cloud_credentials(details: dict[str, Any], msg_lower: st return any(marker in msg_lower for marker in markers) -def _looks_like_runtime_permission_error(details: dict[str, Any], msg_lower: str, code: int | None) -> bool: +def _looks_like_runtime_permission_error( + details: dict[str, Any], msg_lower: str, code: int | None +) -> bool: remote_code = str(details.get("remote_error_code") or "").strip().lower() - if remote_code in {"accessdenied", "accessdeniedexception", "unauthorized", "unauthorizedoperation"}: + if remote_code in { + "accessdenied", + "accessdeniedexception", + "unauthorized", + "unauthorizedoperation", + }: return True if code in {401, 403} and ( ("权限" in msg_lower) @@ -145,7 +152,8 @@ def _looks_like_runtime_permission_error(details: dict[str, Any], msg_lower: str def _credential_setup_hints() -> list[str]: return [ - "请检查当前 shell 或项目 `.env` 中是否设置了 `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY`(兼容 `KS3_ACCESS_KEY` / `KS3_SECRET_KEY`)。", + "请检查当前 shell 或项目 `.env` 中是否设置了 `KSYUN_ACCESS_KEY` / " + "`KSYUN_SECRET_KEY`(兼容 `KS3_ACCESS_KEY` / `KS3_SECRET_KEY`)。", "先到 AgentEngine Runtime 控制台确认账号是否具备运行时权限: https://ksp.console.ksyun.com/#/agentEngineRuntime", "如当前子账号没有权限,请到 IAM 授权页授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize", "如果还没有金山云 AK/SK,请让主账号先到 IAM 控制台创建子账号并生成访问密钥: https://uc.console.ksyun.com/pro/iam/", @@ -154,7 +162,8 @@ def _credential_setup_hints() -> list[str]: def _invalid_credential_hints() -> list[str]: return [ - "请检查当前 shell 或项目 `.env` 中的 `KSYUN_ACCESS_KEY` / `KSYUN_SECRET_KEY` 是否填写正确,且没有多余空格。", + "请检查当前 shell 或项目 `.env` 中的 `KSYUN_ACCESS_KEY` / " + "`KSYUN_SECRET_KEY` 是否填写正确,且没有多余空格。", "确认该 AK/SK 未被禁用、删除或重置,并且属于当前要操作的金山云账号。", "如需确认账号是否具备 AgentEngine Runtime 权限,可先查看: https://ksp.console.ksyun.com/#/agentEngineRuntime", "如果凭证属于子账号但仍然被拒绝,请到 IAM 授权页检查授权: https://uc.console.ksyun.com/pro/iam/#/permission/authorize", @@ -188,7 +197,9 @@ def _matches_resource_not_found(msg_lower: str, args: Sequence[str], *, resource return args[0] == resource -def explain_exception(err: Exception, argv: Optional[Sequence[str]] = None) -> Tuple[str, list[str]]: +def explain_exception( + err: Exception, argv: Optional[Sequence[str]] = None +) -> Tuple[str, list[str]]: cli_error = cli_error_from_exception(err, argv=argv) return cli_error.message, cli_error.hints @@ -384,7 +395,9 @@ def cli_error_from_exception( if code == 404 and len(args) >= 2 and args[0] == "dashboard" and args[1] == "share": summary = "未找到 Dashboard 分享链接或目标 Agent。" - hints.append("请先执行 `agentengine dashboard share list --agent ` 查看分享链接。") + hints.append( + "请先执行 `agentengine dashboard share list --agent ` 查看分享链接。" + ) hints.append("如需先确认 Agent,请执行 `agentengine agent list`。") error_code = "resolution_error" exit_code = EXIT_CODE_RESOLUTION @@ -396,22 +409,34 @@ def cli_error_from_exception( exit_code = EXIT_CODE_RESOLUTION elif code == 404 and _matches_resource_not_found(msg_lower, args, resource="agent"): summary = "未找到 Agent。" - hints.append("请确认 Agent 名称/ID 是否正确,可先执行 `agentengine agent list` 查看已部署 Agent。") + hints.append( + "请确认 Agent 名称/ID 是否正确,可先执行 `agentengine agent list` 查看已部署 Agent。" + ) if len(args) >= 2 and args[0] == "dashboard" and args[1] == "list": hints.append("`agentengine dashboard list` 不是有效命令。") - hints.append("如果要查看分享链接,请使用 `agentengine dashboard share list --agent `。") + hints.append( + "如果要查看分享链接,请使用 " + "`agentengine dashboard share list --agent `。" + ) elif args and args[0] == "dashboard": - hints.append("可显式指定 Agent:`agentengine dashboard open --agent `。") + hints.append( + "可显式指定 Agent:`agentengine dashboard open --agent `。" + ) error_code = "resolution_error" exit_code = EXIT_CODE_RESOLUTION elif code == 404 and _matches_resource_not_found(msg_lower, args, resource="mcp"): summary = "未找到 MCP。" - hints.append("请确认 MCP 名称/ID 是否正确,可先执行 `agentengine mcp list` 查看已部署 MCP。") + hints.append( + "请确认 MCP 名称/ID 是否正确,可先执行 `agentengine mcp list` 查看已部署 MCP。" + ) error_code = "resolution_error" exit_code = EXIT_CODE_RESOLUTION elif code == 404 and _matches_resource_not_found(msg_lower, args, resource="openclaw"): summary = "未找到 OpenClaw。" - hints.append("请确认 OpenClaw 名称/ID 是否正确,可先执行 `agentengine openclaw list` 查看已部署实例。") + hints.append( + "请确认 OpenClaw 名称/ID 是否正确,可先执行 " + "`agentengine openclaw list` 查看已部署实例。" + ) error_code = "resolution_error" exit_code = EXIT_CODE_RESOLUTION elif _looks_like_missing_cloud_credentials(details, msg_lower): diff --git a/ksadk/cli/global_options.py b/ksadk/cli/global_options.py index d865b44a..6dddca02 100644 --- a/ksadk/cli/global_options.py +++ b/ksadk/cli/global_options.py @@ -10,8 +10,7 @@ def _has_long_option(command: click.Command, option_name: str) -> bool: return any( - isinstance(param, click.Option) and option_name in param.opts - for param in command.params + isinstance(param, click.Option) and option_name in param.opts for param in command.params ) diff --git a/ksadk/cli/local_runtime.py b/ksadk/cli/local_runtime.py index 52297de7..5910d3bf 100644 --- a/ksadk/cli/local_runtime.py +++ b/ksadk/cli/local_runtime.py @@ -3,12 +3,11 @@ from __future__ import annotations import os -from pathlib import Path import site import sys +from pathlib import Path from typing import Sequence - LOCAL_RUNTIME_VENV_REEXEC_ENV = "AGENTENGINE_LOCAL_RUNTIME_VENV_REEXEC" LEGACY_WEB_VENV_REEXEC_ENV = "AGENTENGINE_WEB_VENV_REEXEC" diff --git a/ksadk/cli/model_catalog.py b/ksadk/cli/model_catalog.py index ad69f5ff..5da3dfe6 100644 --- a/ksadk/cli/model_catalog.py +++ b/ksadk/cli/model_catalog.py @@ -116,7 +116,9 @@ async def fetch_provider_model_catalog( normalized: list[dict[str, Any]] = [] for raw_model in _extract_catalog_items(payload): item = normalize_model_metadata(raw_model) - item["_provider_raw_model"] = raw_model if isinstance(raw_model, Mapping) else {"id": str(raw_model)} + item["_provider_raw_model"] = ( + raw_model if isinstance(raw_model, Mapping) else {"id": str(raw_model)} + ) normalized.append(item) return normalized @@ -128,7 +130,9 @@ async def fetch_provider_model_metadata( model: str | None, timeout: float = 10.0, ) -> dict[str, Any] | None: - catalog = await fetch_provider_model_catalog(api_base=api_base, api_key=api_key, timeout=timeout) + catalog = await fetch_provider_model_catalog( + api_base=api_base, api_key=api_key, timeout=timeout + ) raw_models = [item.get("_provider_raw_model") or item for item in catalog] raw_match = find_model_in_catalog(raw_models, model) if raw_match is None: diff --git a/ksadk/cli/network_options.py b/ksadk/cli/network_options.py index 61cdfb58..1f3a24bc 100644 --- a/ksadk/cli/network_options.py +++ b/ksadk/cli/network_options.py @@ -38,7 +38,10 @@ def network_options(func): click.option( "--enable-public-access/--disable-public-access", default=None, - help="是否开启公网访问;创建时默认开启,更新已有 Agent 时未指定则保留现有配置(显式传入才覆盖)", + help=( + "是否开启公网访问;创建时默认开启,更新已有 Agent 时未指定则保留现有配置" + "(显式传入才覆盖)" + ), ), click.option("--enable-vpc-access", is_flag=True, default=False, help="开启 VPC 私网访问"), click.option("--vpc-id", default=None, help="VPC ID(开启 VPC 访问时必填)"), @@ -75,7 +78,8 @@ def extract_network_config(config: Mapping[str, Any] | None) -> dict[str, Any]: """Read network from top-level `network` or `deploy.network`.""" if not isinstance(config, Mapping): return {} - deploy_config = config.get("deploy") if isinstance(config.get("deploy"), Mapping) else {} + deploy_value = config.get("deploy") + deploy_config: Mapping[str, Any] = deploy_value if isinstance(deploy_value, Mapping) else {} raw_network = config.get("network") or deploy_config.get("network") or {} return dict(raw_network) if isinstance(raw_network, Mapping) else {} @@ -98,7 +102,12 @@ def _pick(*keys: str, default=None): if _picked_public_access is not None: deploy_target.network.enable_public_access = bool(_picked_public_access) deploy_target.network.enable_vpc_access = bool( - _pick("enable_vpc_access", "enableVpcAccess", "EnableVpcAccess", default=deploy_target.network.enable_vpc_access) + _pick( + "enable_vpc_access", + "enableVpcAccess", + "EnableVpcAccess", + default=deploy_target.network.enable_vpc_access, + ) ) deploy_target.network.vpc_id = str( _pick("vpc_id", "vpcId", "VpcId", default=deploy_target.network.vpc_id) or "" @@ -136,7 +145,9 @@ def apply_network_cli_overrides(deploy_target: "DeployTarget", **network_kwargs: value = network_kwargs.get(field) if value is not None: setattr(deploy_target.network, field, str(value or "").strip()) - if any(str(getattr(deploy_target.network, field, "") or "").strip() for field in _VPC_ID_FIELDS): + if any( + str(getattr(deploy_target.network, field, "") or "").strip() for field in _VPC_ID_FIELDS + ): deploy_target.network.enable_vpc_access = True @@ -145,7 +156,9 @@ def validate_deploy_target_network(deploy_target: "DeployTarget") -> None: values = { "vpc_id": str(getattr(deploy_target.network, "vpc_id", "") or "").strip(), "subnet_id": str(getattr(deploy_target.network, "subnet_id", "") or "").strip(), - "security_group_id": str(getattr(deploy_target.network, "security_group_id", "") or "").strip(), + "security_group_id": str( + getattr(deploy_target.network, "security_group_id", "") or "" + ).strip(), } has_any_vpc_id = any(values.values()) if not (bool(getattr(deploy_target.network, "enable_vpc_access", False)) or has_any_vpc_id): @@ -156,7 +169,8 @@ def validate_deploy_target_network(deploy_target: "DeployTarget") -> None: "开启 VPC 访问时必须同时提供 VpcId、SubnetId、SecurityGroupId。", details={"missing": missing}, hints=[ - "请同时传入 `--vpc-id`、`--subnet-id`、`--security-group-id`,或在配置文件 network/deploy.network 中同时设置这三个字段。", + "请同时传入 `--vpc-id`、`--subnet-id`、`--security-group-id`," + "或在配置文件 network/deploy.network 中同时设置这三个字段。", "`--availability-zone` 是可选字段,不替代子网或安全组。", ], ) @@ -191,8 +205,7 @@ def resolve_deploy_target_network( print_info(f"已根据子网 {subnet_id} 自动推断可用区: {availability_zone}") else: print_warn( - "未能根据子网自动推断可用区;如私网 ENI 调度失败,请显式传入 " - "`--availability-zone`。" + "未能根据子网自动推断可用区;如私网 ENI 调度失败,请显式传入 " "`--availability-zone`。" ) @@ -234,7 +247,9 @@ def _validate_network_payload(payload: Mapping[str, Any]) -> None: ) -def _fill_network_availability_zone(payload: dict[str, Any], network_kwargs: Mapping[str, Any]) -> None: +def _fill_network_availability_zone( + payload: dict[str, Any], network_kwargs: Mapping[str, Any] +) -> None: if str(payload.get("availability_zone") or "").strip(): return if not bool(payload.get("enable_vpc_access")): @@ -254,8 +269,7 @@ def _fill_network_availability_zone(payload: dict[str, Any], network_kwargs: Map print_info(f"已根据子网 {subnet_id} 自动推断可用区: {availability_zone}") else: print_warn( - "未能根据子网自动推断可用区;如私网 ENI 调度失败,请显式传入 " - "`--availability-zone`。" + "未能根据子网自动推断可用区;如私网 ENI 调度失败,请显式传入 " "`--availability-zone`。" ) @@ -270,7 +284,9 @@ def _resolve_subnet_availability_zone(*, subnet_id: str, region: str) -> str | N return None try: - VpcClient, DescribeSubnetsRequest, Credential, ClientProfile, HttpProfile = _import_vpc_sdk() + VpcClient, DescribeSubnetsRequest, Credential, ClientProfile, HttpProfile = ( + _import_vpc_sdk() + ) except Exception as exc: print_warn(f"缺少 VPC 子网查询 SDK,跳过可用区自动推断: {exc}") return None @@ -328,8 +344,12 @@ def _resolve_ksyun_credentials() -> tuple[str, str]: except Exception: global_env = {} - access_key = access_key or global_env.get("KSYUN_ACCESS_KEY") or global_env.get("KS3_ACCESS_KEY") or "" - secret_key = secret_key or global_env.get("KSYUN_SECRET_KEY") or global_env.get("KS3_SECRET_KEY") or "" + access_key = ( + access_key or global_env.get("KSYUN_ACCESS_KEY") or global_env.get("KS3_ACCESS_KEY") or "" + ) + secret_key = ( + secret_key or global_env.get("KSYUN_SECRET_KEY") or global_env.get("KS3_SECRET_KEY") or "" + ) return str(access_key or "").strip(), str(secret_key or "").strip() diff --git a/ksadk/cli/resource_common.py b/ksadk/cli/resource_common.py index a53577bb..f52dda8e 100644 --- a/ksadk/cli/resource_common.py +++ b/ksadk/cli/resource_common.py @@ -2,8 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass, field import re +from dataclasses import dataclass, field from typing import Any, Callable, Iterable, Sequence, TypeVar import click @@ -181,6 +181,7 @@ def confirm_destructive( details={"prompt": prompt}, ) ) + return False if click.confirm(prompt): return True from ksadk.cli.error_utils import abort_with_cli_error, cancelled_error @@ -192,6 +193,7 @@ def confirm_destructive( details={"prompt": prompt}, ) ) + return False def print_list_summary(*, total: int, page: int, size: int, noun: str) -> None: @@ -244,7 +246,9 @@ def get_descriptor_resource_key(descriptor: ResourceDescriptor) -> str: return descriptor.resource_key or _resource_key(descriptor.name) or "resource" -def _legacy_action_descriptors(descriptor: ResourceDescriptor) -> tuple[ResourceActionDescriptor, ...]: +def _legacy_action_descriptors( + descriptor: ResourceDescriptor, +) -> tuple[ResourceActionDescriptor, ...]: action_set = descriptor.actions if not isinstance(action_set, ResourceActionSet): return tuple(action_set) diff --git a/ksadk/cli/storage.py b/ksadk/cli/storage.py index 025ff58a..fb8971cc 100644 --- a/ksadk/cli/storage.py +++ b/ksadk/cli/storage.py @@ -6,7 +6,6 @@ import click - DEFAULT_STORAGE_SIZE_GI = 20 MIN_STORAGE_SIZE_GI = 20 MAX_STORAGE_SIZE_GI = 500 diff --git a/ksadk/cli/ui.py b/ksadk/cli/ui.py index dda4bd0d..7c24c9ec 100644 --- a/ksadk/cli/ui.py +++ b/ksadk/cli/ui.py @@ -2,12 +2,12 @@ from __future__ import annotations -from contextlib import contextmanager, redirect_stderr, redirect_stdout -from dataclasses import dataclass import io import json import os import sys +from contextlib import contextmanager, redirect_stderr, redirect_stdout +from dataclasses import dataclass from typing import Iterator import click @@ -303,7 +303,9 @@ def no_color_option(*, hidden: bool = False, expose_value: bool = False): ) -def build_no_color_click_option(*, hidden: bool = False, expose_value: bool = False) -> click.Option: +def build_no_color_click_option( + *, hidden: bool = False, expose_value: bool = False +) -> click.Option: """Build a shared no-color option for command injection.""" return click.Option( ["--no-color", "no_color"], diff --git a/ksadk/cli/workflow_common.py b/ksadk/cli/workflow_common.py index 652d3b9c..14a66cea 100644 --- a/ksadk/cli/workflow_common.py +++ b/ksadk/cli/workflow_common.py @@ -2,8 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass, replace import json +from dataclasses import dataclass, replace from pathlib import Path from typing import Any, Sequence @@ -16,9 +16,9 @@ print_info, print_kv, print_next_steps, + print_rule, print_title, print_warn, - print_rule, ) @@ -55,9 +55,19 @@ def build_workflow_local_plan( normalized_artifact_type = (artifact_type or "").strip().lower() normalized_reference = str(artifact_reference or artifact_plan.reference or "") source = artifact_plan.source or ("built" if artifact_plan.should_build else "external") - will_build = artifact_plan.will_build if artifact_plan.will_build is not None else artifact_plan.should_build - should_publish = artifact_plan.should_publish if artifact_plan.should_publish is not None else artifact_plan.should_build - will_publish = artifact_plan.will_publish if artifact_plan.will_publish is not None else will_build + will_build = ( + artifact_plan.will_build + if artifact_plan.will_build is not None + else artifact_plan.should_build + ) + should_publish = ( + artifact_plan.should_publish + if artifact_plan.should_publish is not None + else artifact_plan.should_build + ) + will_publish = ( + artifact_plan.will_publish if artifact_plan.will_publish is not None else will_build + ) build_reason = _plan_step_reason( source=source, explicit_ref_option=artifact_plan.explicit_ref_option, @@ -114,13 +124,20 @@ def build_workflow_local_plan( } -def should_build_artifact(*, target: str, artifact_type: str | None, ks3_path: str | None, image: str | None) -> bool: +def should_build_artifact( + *, target: str, artifact_type: str | None, ks3_path: str | None, image: str | None +) -> bool: """Whether deploy/launch should build artifacts locally.""" if target != "serverless": return False 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. + if mode == "managedruntime": + return False if mode == "container": return not bool(image) return False @@ -136,6 +153,7 @@ def plan_artifact_build( repackage: bool = False, ) -> ArtifactBuildPlan: """Plan artifact build behavior and metadata cleanup under cache options.""" + mode = (artifact_type or "Code").strip().lower() should_build = should_build_artifact( target=target, artifact_type=artifact_type, @@ -144,7 +162,6 @@ def plan_artifact_build( ) explicit_ref_option = None if target == "serverless" and not should_build: - mode = (artifact_type or "Code").strip().lower() if mode == "code" and ks3_path: explicit_ref_option = "--ks3-path" elif mode == "container" and image: @@ -154,8 +171,8 @@ def plan_artifact_build( should_clear_metadata=bool((no_cache or repackage) and should_build), explicit_ref_option=explicit_ref_option, will_build=should_build, - should_publish=bool(target == "serverless" and should_build), - will_publish=bool(target == "serverless" and should_build), + should_publish=bool(target == "serverless" and should_build and mode != "managedruntime"), + will_publish=bool(target == "serverless" and should_build and mode != "managedruntime"), source="external" if explicit_ref_option else ("built" if should_build else None), ) @@ -176,7 +193,9 @@ def _predict_artifact_reference( normalized_artifact_type = (artifact_type or "Code").strip().lower() normalized_deploy_name = (deploy_name or "agent").strip() or "agent" - normalized_region = "cn-beijing-6" if str(region or "").strip() == "pre-online" else str(region or "").strip() + normalized_region = ( + "cn-beijing-6" if str(region or "").strip() == "pre-online" else str(region or "").strip() + ) if normalized_artifact_type == "code": bucket = (ks3_bucket or "").strip() @@ -184,7 +203,11 @@ def _predict_artifact_reference( bucket = f"agentengine-{account_id}-{normalized_region}" if not bucket: bucket = "" - return f"ks3://{bucket}/agents/{normalized_deploy_name}/code_.zip" + artifact_label = "runtime" if normalized_artifact_type == "managedruntime" else "code" + return f"ks3://{bucket}/agents/{normalized_deploy_name}/{artifact_label}_.zip" + + if normalized_artifact_type == "managedruntime": + return "inline:managed-runtime" if normalized_artifact_type == "container": normalized_registry = (registry or "").strip().rstrip("/") @@ -212,6 +235,7 @@ def resolve_artifact_build_plan( """Resolve workflow artifact behavior after package metadata is available.""" normalized_explicit = str(explicit_reference or "").strip() normalized_cached = str(cached_reference or "").strip() + is_managed_runtime = (artifact_type or "").strip().lower() == "managedruntime" if normalized_explicit: return replace( @@ -241,7 +265,7 @@ def resolve_artifact_build_plan( return replace( plan, will_build=False, - should_publish=bool(target == "serverless"), + should_publish=bool(target == "serverless" and not is_managed_runtime), will_publish=False, source="planned_build", reference=_predict_artifact_reference( @@ -260,8 +284,8 @@ def resolve_artifact_build_plan( return replace( plan, will_build=True, - should_publish=bool(target == "serverless"), - will_publish=bool(target == "serverless"), + should_publish=bool(target == "serverless" and not is_managed_runtime), + will_publish=bool(target == "serverless" and not is_managed_runtime), source="built", reference=plan.reference, reference_is_predicted=False, @@ -339,7 +363,11 @@ def _request_header_summary(headers: Any) -> str: def _summarize_plan_steps(steps: Sequence[dict[str, Any]]) -> tuple[str, str, str]: executed = [str(step.get("name") or "-") for step in steps if step.get("will_run")] planned = [str(step.get("name") or "-") for step in steps if step.get("planned")] - skipped = [str(step.get("name") or "-") for step in steps if not step.get("will_run") and not step.get("planned")] + skipped = [ + str(step.get("name") or "-") + for step in steps + if not step.get("will_run") and not step.get("planned") + ] return ( ", ".join(executed) or "-", ", ".join(planned) or "-", @@ -516,8 +544,14 @@ def render_workflow_dry_run( print_rule("本地计划") print_kv("制品类型", str(plan.get("artifact_type") or "-")) - print_kv("需要本地构建", "是" if artifact.get("should_local_build", artifact.get("should_build")) else "否") - print_kv("会执行本地构建", "是" if artifact.get("will_local_build", artifact.get("will_build")) else "否") + print_kv( + "需要本地构建", + "是" if artifact.get("should_local_build", artifact.get("should_build")) else "否", + ) + print_kv( + "会执行本地构建", + "是" if artifact.get("will_local_build", artifact.get("will_build")) else "否", + ) print_kv("需要发布制品", "是" if artifact.get("should_publish") else "否") print_kv("会执行制品发布", "是" if artifact.get("will_publish") else "否") if artifact.get("source"): diff --git a/ksadk/codex/__init__.py b/ksadk/codex/__init__.py new file mode 100644 index 00000000..c13f646a --- /dev/null +++ b/ksadk/codex/__init__.py @@ -0,0 +1,12 @@ +"""CodexRuntime — 非 ADK 体系 runtime (goal-09)。""" + +from ksadk.codex.client import AsyncCodexClient, CodexClient +from ksadk.codex.phase import CodexPhaseTracker +from ksadk.codex.runtime import CodexRuntime + +__all__ = [ + "AsyncCodexClient", + "CodexClient", + "CodexPhaseTracker", + "CodexRuntime", +] diff --git a/ksadk/codex/client.py b/ksadk/codex/client.py new file mode 100644 index 00000000..928f22e9 --- /dev/null +++ b/ksadk/codex/client.py @@ -0,0 +1,408 @@ +"""CodexRuntime 的 codex 后端接口 (goal-09)。 + +重托管模式:CodexRuntime 对执行生命周期负责,不把 cancel/进程管理薄委托给上层。 +``CodexClient`` 是 codex 后端(thread/turn 生命周期)的最小接口,方法集对齐 OpenAI +官方 ``openai-codex`` SDK 的**真实**线程模型(``thread_start`` / ``thread.turn`` / +``handle.stream`` / ``handle.interrupt`` / ``thread_resume``): + +- 生产实现 ``AsyncCodexClient``:基于 ``AsyncCodex``。**lazy import**,缺依赖时显式 + 报"请安装 ksadk[codex]",不静默失败;构造时对真实 SDK 方法做 ``hasattr`` 校验, + 版本漂移时 fail-fast,而非运行期 AttributeError。 +- 测试实现:用 fake(见 tests/runners/test_codex_runtime.py / test_adapter_contract.py), + 不需要真 CLI 二进制。 + +诚实边界:本模块的 SDK **方法面**已对安装的 ``openai-codex==0.144.4`` 实证(方法存在性 + +协程/asyncgen 形态);Notification → RuntimeEvent 的**字段级** phase 映射需在接真实 codex +后端时按实况对齐(结构已按生成的 payload 类型映射,见 ``_notification_to_event_dict``)。 +""" + +from __future__ import annotations + +import os +import secrets +from abc import ABC, abstractmethod +from importlib.metadata import PackageNotFoundError, version +from typing import Any, AsyncIterator, Optional + +from ksadk.model_proxy import ProxyConfig, ProxyServer +from ksadk.model_proxy.cache import CapabilityCache, credential_scope +from ksadk.model_proxy.detect import probe_responses_capability + +# 探测缓存单例:能力判定跨 client 共享,按 (model, base, credential_scope) 长缓存 +_CAPABILITY_CACHE = CapabilityCache(ttl=3600) + + +def _is_openai_official(base: str) -> bool: + """OpenAI 官方 base_url(直连,不探测不代理)。""" + from urllib.parse import urlsplit + + host = (urlsplit(base).hostname or "").lower() + return host == "api.openai.com" or host.endswith(".openai.com") + + +def _upgrade_http_to_https(upstream: str) -> str: + """http 非回环自动升级 https(凭证安全 + 兼容历史 http .env;星流等支持 https)。 + + ProxyConfig 强制非回环 https(凭证不裸奔);历史 .env 常写 http://kspmas, + 这里 upgrade 让 codex 代理对它可用,不改通用模板(ADK 等用 http 本就 OK)。 + """ + from urllib.parse import urlsplit, urlunsplit + + parts = urlsplit(upstream) + scheme = parts.scheme.lower() + host = (parts.hostname or "").lower() + if scheme == "http" and host not in ("localhost", "127.0.0.1", "::1"): + return urlunsplit(("https", parts.netloc, parts.path, parts.query, parts.fragment)) + return upstream + + +def _probe_requires_proxy(model: str, base: str, key: str) -> bool: + """探测上游:只有**确凿不支持 responses** 才返回 True(走代理)。 + + supported/unknown 都返回 False(直连)。unknown(故障)保守直连——故障 ≠ 模型 + 不支持 responses,不 silent 改变接入方式。结果经 CapabilityCache 缓存(singleflight)。 + """ + + def probe(m: str, b: str): + import httpx + + with httpx.Client() as client: + return probe_responses_capability(client, b, key, m, timeout=15.0) + + caps = _CAPABILITY_CACHE.get_or_probe(model, base, credential_scope(key), probe) + return caps.verdict == "unsupported" + + +class CodexClient(ABC): + """codex 后端(thread/turn 生命周期)的最小接口(对齐真实 SDK 线程模型)。""" + + @abstractmethod + async def start_thread(self, config: Optional[dict[str, Any]] = None) -> str: + """新建 thread(真实 SDK ``thread_start``),返回 thread_id。""" + raise NotImplementedError + + @abstractmethod + def run_turn( + self, + thread_id: str, + prompt: Any, + *, + config: Optional[dict[str, Any]] = None, + ) -> AsyncIterator[dict[str, Any]]: + """在 thread 上跑一个 turn(``thread.turn`` + ``handle.stream``), + 产出规范化事件 dict(``{"method": ..., "params": ...}``)。""" + raise NotImplementedError + + @abstractmethod + async def interrupt_active_turn(self, thread_id: str) -> bool: + """interrupt 该 thread 当前活跃 turn(真实 SDK ``handle.interrupt``); + 无活跃 turn 返回 False。""" + raise NotImplementedError + + @abstractmethod + async def resume_thread(self, thread_id: str, config: Optional[dict[str, Any]] = None) -> str: + """恢复 thread(真实 SDK ``thread_resume``),返回 thread_id。""" + raise NotImplementedError + + @abstractmethod + async def close(self) -> None: + """释放后端资源(真实 SDK ``close``)。""" + raise NotImplementedError + + +def _missing_codex_error() -> RuntimeError: + return RuntimeError( + "CodexRuntime 需要 openai-codex SDK;请安装 ksadk[codex]:" + " pip install 'ksadk[codex]'(openai-codex 为可选 extra,不进默认依赖)" + ) + + +class AsyncCodexClient(CodexClient): + """基于 OpenAI 官方 ``openai-codex`` SDK(``AsyncCodex``)的生产后端。 + + **lazy import** ``openai_codex``:仅在实际使用时导入,缺依赖时显式报错 + (``pip install 'ksadk[codex]'``),不让所有 ksadk 用户默认背几十 MB CLI 二进制。 + 构造时校验真实 SDK 方法存在(``thread_start``/``thread_resume``/``close`` 及 + ``AsyncThread.turn`` / ``AsyncTurnHandle.stream``/``interrupt``),版本漂移 fail-fast。 + """ + + def __init__(self, config: Any = None) -> None: + try: + from openai_codex import ( # type: ignore[import-not-found] + AsyncCodex, + AsyncThread, + AsyncTurnHandle, + ) + except ImportError as exc: + raise _missing_codex_error() from exc + + try: + sdk_version = version("openai-codex") + except PackageNotFoundError: + sdk_version = "unknown" + required = ( + (AsyncCodex, "thread_start"), + (AsyncCodex, "thread_resume"), + (AsyncCodex, "close"), + (AsyncThread, "turn"), + (AsyncTurnHandle, "stream"), + (AsyncTurnHandle, "interrupt"), + ) + for owner, method_name in required: + if not hasattr(owner, method_name): + raise RuntimeError( + f"openai-codex {sdk_version} SDK 缺少 " + f"{owner.__name__}.{method_name}(版本不兼容)" + ) + + # AsyncCodex 0.144.4 only accepts one CodexConfig positional/keyword. + config, self._proxy = self._maybe_apply_proxy(config) + 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 + + @staticmethod + def _maybe_apply_proxy(config: Any) -> tuple[Any, Any]: + """codex 代理启用:**智能探测 fallback(带显式覆盖)**。 + + - ``KSADK_CODEX_USE_PROXY=1`` → 强制开代理;``=0`` → 强制直连(可人工覆盖误判)。 + - **未设 env 时智能探测**:OpenAI 官方 base_url 直连(不探测);自定义上游 + (星流等)探测 responses 能力(detect.py + CapabilityCache 缓存,一次探测长缓存): + - ``supported`` → 直连(原生 responses 可用) + - ``unsupported`` → 自动启用代理(chat 模型,经转换层) + - ``unknown``(故障/超时)→ **保守直连**,不 silent 改变接入方式 + - 凭证闭合:codex 子进程只拿随机 KSADK_PROXY_TOKEN;上游 key 留父进程。 + - 互斥:launch_args_override 已设时 raise(override 整体覆盖命令行)。 + + 返回 (新 config, ProxyServer | None)。staticmethod 便于单测。 + """ + env_val = os.environ.get("KSADK_CODEX_USE_PROXY") + if env_val == "0": + return config, None + if env_val == "1": + return AsyncCodexClient._start_proxy_and_inject(config) + # 未设:智能探测 + base = ( + os.environ.get("KSADK_PROXY_UPSTREAM_BASE") + or os.environ.get("OPENAI_BASE_URL") + or os.environ.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 "" + if _probe_requires_proxy(model, base, key): + return AsyncCodexClient._start_proxy_and_inject(config) + return config, None + + @staticmethod + def _start_proxy_and_inject(config: Any) -> tuple[Any, Any]: + """起进程内 ProxyServer + 注入 codex provider(opt-in/探测判定走代理时)。""" + import dataclasses + + from openai_codex import CodexConfig # type: ignore[import-not-found] + + cfg = config if isinstance(config, CodexConfig) else CodexConfig() + if cfg.launch_args_override is not None: + raise RuntimeError( + "KSADK_CODEX_USE_PROXY 与 CodexConfig.launch_args_override 互斥:" + "代理注入靠 config_overrides,而 launch_args_override 会整体覆盖命令行" + ) + upstream = ( + os.environ.get("KSADK_PROXY_UPSTREAM_BASE") + or os.environ.get("OPENAI_BASE_URL") + or os.environ.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 "" + ) + token = secrets.token_hex(16) + proxy = ProxyServer(ProxyConfig(upstream_base=upstream, api_key=api_key, local_token=token)) + proxy.start() + overrides = list(cfg.config_overrides or ()) + overrides += [ + "model_provider=ksadk_proxy", + "model_providers.ksadk_proxy.name=ksadk_proxy", + f"model_providers.ksadk_proxy.base_url={proxy.base_url}", + "model_providers.ksadk_proxy.env_key=KSADK_PROXY_TOKEN", + "model_providers.ksadk_proxy.wire_api=responses", + "model_providers.ksadk_proxy.supports_websockets=false", + "web_search=disabled", + "features.multi_agent=false", + "features.multi_agent_v2=false", + ] + env = dict(cfg.env or {}) + 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)) + self._threads[thread.id] = thread + return str(thread.id) + + async def resume_thread(self, thread_id: str, config: Optional[dict[str, Any]] = None) -> str: + # An ephemeral thread has no rollout on disk, so SDK thread_resume would + # fail with -32600. Reuse its live AsyncThread for same-process resume. + 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) + self._threads[thread.id] = thread + return str(thread.id) + + def run_turn( + self, + thread_id: str, + prompt: Any, + *, + config: Optional[dict[str, Any]] = None, + ) -> AsyncIterator[dict[str, Any]]: + return self._run_turn_gen(thread_id, prompt, config) + + async def _run_turn_gen( + self, thread_id: str, prompt: Any, config: Optional[dict[str, Any]] + ) -> AsyncIterator[dict[str, Any]]: + thread = self._threads.get(thread_id) + if thread is None: + # 未显式 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)) + self._active_handles[thread_id] = handle + try: + async for notification in handle.stream(): + event = self._notification_to_event_dict(notification) + if event is not None: + yield event + finally: + self._active_handles.pop(thread_id, None) + + async def interrupt_active_turn(self, thread_id: str) -> bool: + handle = self._active_handles.get(thread_id) + if handle is None: + return False + await handle.interrupt() + return True + + async def close(self) -> None: + try: + await self._codex.close() + finally: + self._active_handles.clear() + self._threads.clear() + if self._proxy is not None: + self._proxy.stop() + self._proxy = None + + # ---- 内部:config / input / 事件映射 ---- + + @staticmethod + def _thread_kwargs(config: Optional[dict[str, Any]]) -> dict[str, Any]: + """Map runtime config to the exact 0.144.4 thread API surface.""" + from openai_codex import ApprovalMode, Sandbox # type: ignore[import-not-found] + + config = config or {} + known = { + "approval_mode", + "model", + "model_provider", + "cwd", + "sandbox", + "service_tier", + "base_instructions", + "developer_instructions", + "ephemeral", + } + result = {k: v for k, v in config.items() if k in known} + # KSADK sessions are ephemeral so an interrupted/half-written turn cannot + # later be revived from Codex's on-disk session store. + result.setdefault("ephemeral", True) + if config.get("sandbox_read_only", False): + result["sandbox"] = Sandbox.read_only + result.setdefault("approval_mode", ApprovalMode.deny_all) + else: + result["sandbox"] = _coerce_enum(Sandbox, result.get("sandbox")) + result["approval_mode"] = _coerce_enum(ApprovalMode, result.get("approval_mode")) + return {key: value for key, value in result.items() if value is not None} + + @staticmethod + def _turn_kwargs(config: Optional[dict[str, Any]]) -> dict[str, Any]: + """Map runtime config to the exact 0.144.4 AsyncThread.turn surface.""" + from openai_codex import ApprovalMode, Sandbox # type: ignore[import-not-found] + + config = config or {} + known = { + "approval_mode", + "cwd", + "effort", + "model", + "output_schema", + "personality", + "sandbox", + "service_tier", + "summary", + } + result = {key: value for key, value in config.items() if key in known} + if config.get("sandbox_read_only", False): + result["sandbox"] = Sandbox.read_only + result.setdefault("approval_mode", ApprovalMode.deny_all) + else: + result["sandbox"] = _coerce_enum(Sandbox, result.get("sandbox")) + result["approval_mode"] = _coerce_enum(ApprovalMode, result.get("approval_mode")) + return {key: value for key, value in result.items() if value is not None} + + @staticmethod + def _coerce_input(prompt: Any) -> Any: + # RunInput 接受 str 或 [TextInput|...];str 直接透传。 + return prompt if isinstance(prompt, str) else prompt + + @staticmethod + def _notification_to_event_dict(notification: Any) -> Optional[dict[str, Any]]: + """把真实 ``Notification``(method + 类型化 payload)映射为运行时消费的规范化 dict。 + + 按 payload 类型路由(结构已对生成的 payload 类型实证);字段级 phase 细节在接 + 真实后端时对齐。 + """ + payload = notification.payload + if hasattr(payload, "model_dump"): + params = payload.model_dump(mode="json") + else: + params = getattr(payload, "params", None) + if not isinstance(params, dict): + params = {} + + method = str(getattr(notification, "method", "")) + supported_methods = { + "item/started", + "item/completed", + "item/agentMessage/delta", + "item/autoApprovalReview/started", + "item/autoApprovalReview/completed", + "error", + } + if method in supported_methods: + return {"method": method, "params": params} + return None + + +def _coerce_enum(enum_type: Any, value: Any) -> Any: + if value is None or isinstance(value, enum_type): + return value + try: + return enum_type(value) + except (TypeError, ValueError) as exc: + supported = ", ".join(member.value for member in enum_type) + raise ValueError( + f"unsupported {enum_type.__name__} {value!r}; expected {supported}" + ) from exc + + +__all__ = ["AsyncCodexClient", "CodexClient"] diff --git a/ksadk/codex/phase.py b/ksadk/codex/phase.py new file mode 100644 index 00000000..4995ea38 --- /dev/null +++ b/ksadk/codex/phase.py @@ -0,0 +1,143 @@ +"""codex_phase — Codex app-server agent message 的相位(phase)翻译 (goal-09 契约 2)。 + +镜像 `Wegent/executor/src/codex_phase.rs`: + +- 流式 live 事件**不在每个 delta 上重复相位**——``item/started`` 带 + ``params.item.id`` + ``params.item.phase``;后续 ``item/agentMessage/delta`` 只带 + ``params.itemId`` + ``params.delta``(无 phase)。 +- 因此必须**按 itemId 追踪相位,按解析出的相位路由 delta**;**不从文本内容推断相位**, + 不等 ``item/completed`` 才更新 UI。 +- 映射到 RuntimeEvent schema 的 ``phase`` 字段(commentary/final_answer), + **相位翻译独立成模块,不揉进 CodexRuntime 主逻辑,不混入最终答案**。 + +Codex 相位:``commentary`` / ``analysis`` 属过程解说(process),``final_answer`` 是 +最终答案。映射到 RuntimeEvent:process → ``commentary``,final_answer → ``final_answer``。 +""" + +from __future__ import annotations + +from typing import Any, Optional + +#: Codex 相位 → RuntimeEvent phase 字段。analysis/commentary 都是过程解说。 +_PROCESS_PHASES = frozenset({"analysis", "commentary"}) + +_PHASE_KEYS = ("phase", "channel") +_ITEM_ID_KEYS = ("itemId", "item_id", "id", "messageId", "message_id") + + +def _normalize_phase(value: str) -> str: + # Pydantic's Python-mode dump renders enums as ``MessagePhase.final_answer``. + # JSON-mode is used by the production client, while accepting this spelling + # keeps replayed samples from older KSADK builds readable. + return value.rsplit(".", 1)[-1].replace("_", "").replace("-", "").strip().lower() + + +def codex_phase_name(value: dict[str, Any]) -> Optional[str]: + """从事件 params 里读相位字段(phase / channel)并归一化。""" + if not isinstance(value, dict): + return None + for key in _PHASE_KEYS: + raw = value.get(key) + if isinstance(raw, str): + normalized = _normalize_phase(raw) + if normalized: + return normalized + return None + + +def codex_item_id(value: dict[str, Any]) -> Optional[str]: + """从事件 params 里读 itemId(itemId/item_id/id/messageId/message_id)。""" + if not isinstance(value, dict): + return None + for key in _ITEM_ID_KEYS: + raw = value.get(key) + if isinstance(raw, str) and raw: + return raw + return None + + +def runtime_phase_for(codex_phase: Optional[str]) -> Optional[str]: + """把 codex 相位映射为 RuntimeEvent phase(commentary / final_answer)。""" + if codex_phase is None: + return None + if codex_phase in _PROCESS_PHASES: + return "commentary" + if codex_phase == "finalanswer" or codex_phase == "final_answer": + return "final_answer" + return None + + +def _item_params(params: dict[str, Any]) -> dict[str, Any]: + """取事件里嵌套的 ``item`` 子对象(若无则返回 params 本身),类型收窄为 dict。""" + item = params.get("item") + return item if isinstance(item, dict) else params + + +class CodexPhaseTracker: + """按 itemId 追踪 assistant message 的相位,按相位路由流式 delta。""" + + def __init__(self) -> None: + self._phases_by_item_id: dict[str, str] = {} + + def observe_item(self, params: dict[str, Any]) -> None: + """item/started(或 reload/completed):记录 itemId -> phase。 + + codex 真实流 agentMessage 的 phase 常为 null,但 agentMessage 就是最终回复 + (final_answer),reasoning 才是 commentary。phase 为 null 时按 item type 兜底: + agentMessage → final_answer,reasoning → commentary。 + """ + item = _item_params(params) + item_id = codex_item_id(item) or codex_item_id(params) + phase = codex_phase_name(item) or codex_phase_name(params) + if not phase and item_id: + item_type = item.get("type") if isinstance(item, dict) else None + if item_type == "agentMessage": + phase = "final_answer" + elif item_type == "reasoning": + phase = "commentary" + if item_id and phase: + self._phases_by_item_id[item_id] = phase + + def phase_for_delta(self, params: dict[str, Any]) -> Optional[str]: + """item/agentMessage/delta:优先事件自带 phase,否则按 itemId 查追踪表。""" + direct = codex_phase_name(params) + if direct: + return direct + item_id = codex_item_id(params) + if item_id: + return self._phases_by_item_id.get(item_id) + return None + + def phase_for_item(self, params: dict[str, Any]) -> Optional[str]: + """item/completed / reload:事件或 item 内的 phase,再退回追踪表。""" + item = _item_params(params) + direct = codex_phase_name(item) or codex_phase_name(params) + if direct: + return direct + item_id = codex_item_id(item) or codex_item_id(params) + if item_id: + return self._phases_by_item_id.get(item_id) + return None + + def runtime_phase_for_delta(self, params: dict[str, Any]) -> Optional[str]: + """delta 的 RuntimeEvent phase(commentary/final_answer)。""" + return runtime_phase_for(self.phase_for_delta(params)) + + def runtime_phase_for_item(self, params: dict[str, Any]) -> Optional[str]: + """item 的 RuntimeEvent phase(commentary/final_answer)。""" + return runtime_phase_for(self.phase_for_item(params)) + + def forget_item(self, params: dict[str, Any]) -> None: + """item/completed 后清理追踪项。""" + item = _item_params(params) + item_id = codex_item_id(item) or codex_item_id(params) + if item_id: + self._phases_by_item_id.pop(item_id, None) + + +__all__ = [ + "CodexPhaseTracker", + "codex_item_id", + "codex_phase_name", + "runtime_phase_for", +] diff --git a/ksadk/codex/runtime.py b/ksadk/codex/runtime.py new file mode 100644 index 00000000..dbe50716 --- /dev/null +++ b/ksadk/codex/runtime.py @@ -0,0 +1,449 @@ +"""CodexRuntime — 非 ADK 体系的第三验证样本,按 Wegent 重托管模式 (goal-09)。 + +对执行生命周期负责(不做 veadk 式薄桥接),后端能力面对齐 ``openai-codex`` SDK 真实线程模型 +(``thread_start``/``thread.turn``/``handle.stream``/``handle.interrupt``/``thread_resume``): + +- **cancel 状态机**(不薄委托给上层):活跃 turn → ``client.interrupt_active_turn`` + (真实 SDK ``handle.interrupt``,终止当前 turn 执行;thread 由 codex 后端托管,无"杀进程" + 概念);无活跃 turn → 记 pending 下个 turn 消费;**级联丢弃 pending 工具审批**(runtime + 自跟踪的 pending 集);返回 ``CancelResult`` 枚举;**被中断的 turn 不持久化其 session** + (避免 resume 捡到写了一半的会话)。 +- **phase 翻译**(:mod:`ksadk.codex.phase`):按 itemId 路由 commentary/final_answer delta, + 映射 RuntimeEvent phase,不混入主逻辑。 +- **resume 建模为 thread id**(真实 SDK ``thread_resume``),不套 ADK invocation 模型; + ResumeTarget(thread id)/ResumePayload(工具结果/HITL 回答)分离。 + +环境约束:read-only sandbox(默认),单轮含一次工具调用即可跑通。 +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Optional + +from ksadk.codex.client import CodexClient +from ksadk.codex.phase import CodexPhaseTracker +from ksadk.events.runtime_event import EventType, RuntimeEvent +from ksadk.runtime.adapter import ( + BaseRuntime, + CancelResult, + CheckpointCapability, + CheckpointDescriptor, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeAdapter, + StartRequest, +) + +logger = logging.getLogger(__name__) + + +class _CodexAsBaseRuntime(BaseRuntime): + """把 CodexClient 包装为 BaseRuntime(原生能力面)。""" + + def __init__(self, client: CodexClient) -> None: + self._client = client + self.runtime_type = "codex" + + def native_capabilities(self) -> dict[str, Any]: + return {"Framework": "codex", "cancel": "thread", "resume": "thread_id"} + + +@dataclass +class _CodexThread: + thread_id: str + turn_id: Optional[str] = None + streaming: bool = False + interrupt_event: asyncio.Event = field(default_factory=asyncio.Event) + pending_approvals: set[str] = field(default_factory=set) + done: bool = False + interrupted: bool = False + + +class CodexRuntime(RuntimeAdapter): + """Codex 的 RuntimeAdapter(重托管)。""" + + def __init__( + self, + client: CodexClient, + *, + sandbox_read_only: bool = True, + turn_timeout_seconds: Optional[float] = None, + ) -> None: + super().__init__(_CodexAsBaseRuntime(client)) + self._client = client + self._sandbox_read_only = sandbox_read_only + self._turn_timeout_seconds = turn_timeout_seconds + self._threads: dict[str, _CodexThread] = {} + self._known_threads: set[str] = set() + self._pending_cancels: set[str] = set() + # 被杀/interrupt 的 session 不持久化(goal-09 契约 1)。 + self._do_not_persist: set[str] = set() + # 可观测:最近一次 cancel 级联丢弃的审批集(contract test 断言用)。 + self.last_cancel_dropped_approvals: set[str] = set() + self._seq = 0 + + # ---- 六动词 ---- + + async def start(self, request: StartRequest) -> RunHandle: + # 新 thread 由后端分配真实 thread_id(thread_start);metadata 携带的 thread_id + # 表示接入既有 thread(resume 语义,run_turn 时按 resume 接入)。 + provided = request.metadata.get("thread_id") + if provided: + thread_id = str(provided) + else: + # 把 model + base_instructions 传给 codex thread(配置契约,见 plan C) + thread_config: dict[str, Any] = {"sandbox_read_only": self._sandbox_read_only} + if request.model: + thread_config["model"] = request.model + base_instructions = request.config.get("base_instructions") + if base_instructions: + thread_config["base_instructions"] = base_instructions + 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 + return RunHandle( + run_id=thread_id, + session_id=request.session_id, + runtime_type="codex", + native_ref={"thread_id": thread_id, "user_id": request.user_id}, + ) + + def stream(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent]: + return self._stream_events(handle) + + async def cancel(self, handle: RunHandle) -> CancelResult: + thread_id = handle.run_id + thread = self._threads.get(thread_id) + is_active = thread is not None and not thread.done and thread.streaming + if not is_active: + if thread_id in self._known_threads: + self._pending_cancels.add(thread_id) + return CancelResult.PENDING_CANCEL_RECORDED + return CancelResult.NOT_RUNNING + assert thread is not None + try: + # 级联丢弃 pending 工具审批(快照 runtime 自跟踪的 pending 集,供观测)。 + # 真实 SDK 无独立 drain API:interrupt 后 turn 停止,pending 审批随之失效。 + self.last_cancel_dropped_approvals = set(thread.pending_approvals) + thread.pending_approvals.clear() + # 真实中断:handle.interrupt() 停活跃 turn(真实 SDK 机制;无"杀进程"概念, + # thread 由 codex 后端托管,interrupt 即终止当前 turn 的执行)。 + interrupted = 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._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 resume( + self, + handle: RunHandle, + target: ResumeTarget, + payload: Optional[ResumePayload], + ) -> RunHandle: + # resume 用 thread id 语义(resume_thread_id),不套 ADK invocation 模型。 + if target.kind != "thread_id": + raise ValueError(f"CodexRuntime 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} + 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}) + 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 + # 与 ADK/LangGraph 一致的 resume_input 结构(供共用 contract test 断言)。 + handle.native_ref["resume_input"] = { + "type": "codex.resume_thread", + "thread_id": target.id, + "payload": payload.data if payload else None, + "payload_kind": payload.kind if payload else None, + "call_id": payload.call_id if payload else None, + } + return handle + + async def checkpoint(self, handle: RunHandle) -> CheckpointDescriptor: + return CheckpointDescriptor( + checkpoint_id=str(handle.native_ref.get("thread_id") or handle.run_id), + invocation_id=handle.run_id, + capability=CheckpointCapability( + supported=True, + granularity="snapshot", + rollback_scope="turn", + fork_supported=True, + durable=False, + shared_across_pods=False, + reason="Codex resume/fork by thread id", + ), + ref={"thread_id": handle.native_ref.get("thread_id")}, + ) + + async def close(self, handle: RunHandle) -> None: + thread = self._threads.pop(handle.run_id, None) + if thread is not None: + thread.interrupt_event.set() + try: + active_thread_id = thread.thread_id if thread is not None else handle.run_id + await self._client.interrupt_active_turn(active_thread_id) + finally: + # AsyncCodex.close owns terminate/wait/kill for the app-server child. + await self._client.close() + self._do_not_persist.add(handle.run_id) + self._known_threads.discard(handle.run_id) + self._pending_cancels.discard(handle.run_id) + + # ---- stream → RuntimeEvent(phase 翻译 + 中断竞速) ---- + + def _next_thread_id(self) -> str: + self._seq += 1 + return f"codex_thread_{self._seq}" + + def _next_seq(self) -> int: + self._seq += 1 + return self._seq + + async def _stream_events(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent]: + thread = self._threads.get(handle.run_id) + if thread is None: + thread = _CodexThread(thread_id=handle.run_id) + self._threads[handle.run_id] = thread + + if handle.run_id in self._pending_cancels: + self._pending_cancels.discard(handle.run_id) + yield self._event( + handle, + EventType.RUN_CANCELED, + { + "status": "cancelled", + "cancel_result": CancelResult.PENDING_CANCEL_RECORDED.value, + }, + ) + return + + yield self._event(handle, EventType.RUN_STARTED, {"status": "in_progress"}) + tracker = CodexPhaseTracker() + request = thread.__dict__.get("_start_request") + resume_state = thread.__dict__.get("_resume") + if request is not None: + prompt = request.input + elif resume_state is not None: + prompt = _resume_prompt(resume_state.get("payload")) + else: + 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): + yield event + # 正常结束(非 interrupt):补 RUN_COMPLETED(AGUI 投射器据此发 RunFinished success) + if not thread.interrupted: + yield self._event(handle, EventType.RUN_COMPLETED, {"status": "completed"}) + except TimeoutError: + self.last_cancel_dropped_approvals = set(thread.pending_approvals) + thread.pending_approvals.clear() + self._do_not_persist.add(handle.run_id) + # Closing the SDK transport terminates and waits for the app-server + # child even when the stream is stuck between notifications. + await self._client.close() + yield self._event( + handle, + EventType.RUN_FAILED, + {"status": "failed", "error": "codex turn timed out"}, + ) + except Exception as exc: # noqa: BLE001 通用兜底:任何异常都发 RUN_FAILED + self._do_not_persist.add(handle.run_id) + yield self._event( + handle, + EventType.RUN_FAILED, + {"status": "failed", "error": str(exc)}, + ) + finally: + thread.streaming = False + thread.done = True + self._threads.pop(handle.run_id, None) + + async def _map_codex_stream( + self, + handle: RunHandle, + thread: _CodexThread, + tracker: CodexPhaseTracker, + prompt: Any, + ) -> AsyncIterator[RuntimeEvent]: + codex_gen = self._client.run_turn( + thread.thread_id, + prompt, + config={"sandbox_read_only": self._sandbox_read_only}, + ) + deadline = ( + asyncio.get_running_loop().time() + self._turn_timeout_seconds + if self._turn_timeout_seconds is not None + else None + ) + try: + while True: + chunk_task = asyncio.ensure_future(_anext_or_stop(codex_gen)) + interrupt_task = asyncio.ensure_future(thread.interrupt_event.wait()) + remaining = ( + max(0.0, deadline - asyncio.get_running_loop().time()) + if deadline is not None + else None + ) + done, pending = await asyncio.wait( + {chunk_task, interrupt_task}, + timeout=remaining, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + # Keep handle.stream's turn queue registered until transport + # close calls SDK MessageRouter.fail_all. Cancelling the + # asyncio.to_thread waiter first would strand queue.get in + # the executor and hang interpreter shutdown. + interrupt_task.cancel() + 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() + thread.interrupted = True + # AGUI 投射器对 RUN_INTERRUPTED 无兜底,必须显式发,否则 raise + yield self._event( + handle, EventType.RUN_INTERRUPTED, {"status": "interrupted"} + ) + return + chunk = chunk_task.result() + if chunk is _STREAM_STOP: + return + event = self._codex_chunk_to_event(handle, thread, tracker, chunk) + if event is not None: + yield event + finally: + aclose = getattr(codex_gen, "aclose", None) + if callable(aclose): + try: + await aclose() + except Exception: # noqa: BLE001 + pass + + def _codex_chunk_to_event( + self, + handle: RunHandle, + thread: _CodexThread, + tracker: CodexPhaseTracker, + chunk: dict[str, Any], + ) -> Optional[RuntimeEvent]: + if not isinstance(chunk, dict): + return None + method = str(chunk.get("method") or chunk.get("type") or "") + params = chunk.get("params") or chunk + + if method == "item/started": + tracker.observe_item(params) + return None + if method == "item/completed": + item = params.get("item") or params + if item.get("type") != "agentMessage": + tracker.forget_item(params) + return None + phase = tracker.runtime_phase_for_item(params) + tracker.forget_item(params) + text = str(item.get("text") or "") + return self._event( + handle, + EventType.TEXT_COMPLETED, + {"text": text}, + phase=phase or "final_answer", + ) + if "delta" in method or method == "item/agentMessage/delta": + phase = tracker.runtime_phase_for_delta(params) + delta = str(params.get("delta") or "") + if not delta: + return None + return self._event( + handle, EventType.TEXT_DELTA, {"text": delta}, phase=phase or "commentary" + ) + if method == "item/autoApprovalReview/started": + review_id = str(params.get("review_id") or params.get("reviewId") or "") + if review_id: + thread.pending_approvals.add(review_id) + return None + if method == "item/autoApprovalReview/completed": + review_id = str(params.get("review_id") or params.get("reviewId") or "") + thread.pending_approvals.discard(review_id) + return None + if ( + "approval" in method + or "requestPermission" in method + or "approval" in str(chunk.get("type") or "") + ): + call_id = str( + params.get("id") or params.get("call_id") or params.get("requestId") or "" + ) + if call_id: + thread.pending_approvals.add(call_id) + return self._event( + handle, + EventType.APPROVAL_REQUESTED, + {"approval_id": call_id, "call_id": call_id, "kind": "tool", "detail": params}, + ) + return None + + def _event( + self, + handle: RunHandle, + event_type: str, + payload: dict, + *, + phase: Optional[str] = None, + ) -> RuntimeEvent: + return RuntimeEvent.create( + event_type, + agent_id="codex", + 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=phase, + payload=payload, + ) + + +_STREAM_STOP = object() + + +async def _anext_or_stop(gen: AsyncIterator[Any]) -> Any: + try: + return await gen.__anext__() + except StopAsyncIteration: + return _STREAM_STOP + + +def _resume_prompt(payload: Optional[ResumePayload]) -> Any: + if payload is None: + return "" + if isinstance(payload.data, str): + return payload.data + return json.dumps(payload.data, ensure_ascii=False, sort_keys=True) + + +__all__ = ["CodexRuntime"] diff --git a/ksadk/common/aicp_env.py b/ksadk/common/aicp_env.py index 95a52b08..6d63e252 100644 --- a/ksadk/common/aicp_env.py +++ b/ksadk/common/aicp_env.py @@ -5,7 +5,6 @@ import os import socket - DEFAULT_AICP_REGION = "cn-beijing-6" DEFAULT_AICP_ENDPOINT = "aicp.api.ksyun.com" INTERNAL_AICP_ENDPOINT = "aicp.internal.api.ksyun.com" diff --git a/ksadk/common/auth.py b/ksadk/common/auth.py index 8dc6539a..ec4f8d53 100644 --- a/ksadk/common/auth.py +++ b/ksadk/common/auth.py @@ -22,11 +22,11 @@ response = requests.post(url, auth=auth.get_auth(), headers=headers) """ -import os import logging +import os from typing import Dict, Optional -from requests_aws4auth import AWS4Auth +from requests_aws4auth import AWS4Auth # type: ignore[import-untyped] logger = logging.getLogger(__name__) diff --git a/ksadk/common/constants.py b/ksadk/common/constants.py index f372efae..a1c106c1 100644 --- a/ksadk/common/constants.py +++ b/ksadk/common/constants.py @@ -3,9 +3,7 @@ """ # Serverless Endpoint (默认内网预发环境) -DEFAULT_SERVERLESS_ENDPOINT = ( - "http://kmr.pre-online.inner.api.ksyun.com" -) +DEFAULT_SERVERLESS_ENDPOINT = "http://kmr.pre-online.inner.api.ksyun.com" # KS3 Region 映射表 # 用户输入的 region (如 cn-beijing-6) -> (外网endpoint, 内网endpoint, region_code) diff --git a/ksadk/common/llm_utils.py b/ksadk/common/llm_utils.py index 15c8acb8..b9322c43 100644 --- a/ksadk/common/llm_utils.py +++ b/ksadk/common/llm_utils.py @@ -1,7 +1,7 @@ """LLM Utilities""" -import os import logging +import os logger = logging.getLogger(__name__) @@ -10,28 +10,32 @@ # Internal Endpoint (Auto-injected by Platform) # Note: In most cases, we don't need to hardcode this because the platform injects it. -# But for the purpose of "smart switching based on public URL", we might need to know what to switch TO, +# But for "smart switching based on public URL", we might need to know +# what to switch TO, # or simply rely on leaving it empty so the platform default takes over? -# +# # According to user: "OPENAI_BASE_URL 不填会自动选择的" (If empty, it auto-selects). -# So our strategy: If Public URL + Internal Env -> Unset OPENAI_BASE_URL (letting it fall back to platform default). +# Strategy: If Public URL + Internal Env -> Unset OPENAI_BASE_URL, +# letting it fall back to the platform default. + def get_smart_openai_env() -> None: """Smartly configure OpenAI Environment Variables. - + If it detects that the code is running in a Ksyun Internal Environment (Serverless/KCE) AND the OPENAI_BASE_URL is set to the Public Endpoint, it will unset OPENAI_BASE_URL to allow the platform's automatic internal endpoint injection to take effect. - - This prevents network hangs caused by accessing public endpoints from internal-only environments. + + This prevents network hangs caused by accessing public endpoints from + internal-only environments. """ base_url = os.environ.get("OPENAI_BASE_URL", "").strip() - + # logic 1: Check if it is the Public Endpoint # Normalize by removing trailing slash for comparison normalized_url = base_url.rstrip("/") normalized_public = PUBLIC_ENDPOINT.rstrip("/") - + if normalized_url != normalized_public: return @@ -41,11 +45,11 @@ def get_smart_openai_env() -> None: # - K_SERVICE (Injected by Knative/Serverless) # - KUBERNETES_SERVICE_HOST (Injected by K8s) is_internal = ( - "KSYUN_REGION" in os.environ + "KSYUN_REGION" in os.environ or "K_SERVICE" in os.environ or "KUBERNETES_SERVICE_HOST" in os.environ ) - + if is_internal: logger.warning( f"Detected Public OPENAI_BASE_URL ({base_url}) in Internal Environment. " @@ -55,7 +59,7 @@ def get_smart_openai_env() -> None: # Or explicitly set it to internal generic address if known? # User said: "OPENAI_BASE_URL 不填会自动选择". So we unset it. del os.environ["OPENAI_BASE_URL"] - + # Verify if successful removal if "OPENAI_BASE_URL" not in os.environ: - logger.info("Successfully switched to Internal Endpoint mode.") + logger.info("Successfully switched to Internal Endpoint mode.") diff --git a/ksadk/compat/__init__.py b/ksadk/compat/__init__.py new file mode 100644 index 00000000..ffe0084c --- /dev/null +++ b/ksadk/compat/__init__.py @@ -0,0 +1,5 @@ +"""兼容层包 (goal-00)。""" + +from ksadk.compat import adk_compat + +__all__ = ["adk_compat", "copilotkit_a2ui"] diff --git a/ksadk/compat/adk_compat.py b/ksadk/compat/adk_compat.py new file mode 100644 index 00000000..a7f73b3a --- /dev/null +++ b/ksadk/compat/adk_compat.py @@ -0,0 +1,200 @@ +"""Google ADK 多版本兼容层 (goal-00)。 + +ksadk 业务代码**不直接** ``import google.adk``;统一从本模块取符号。 +``google-adk`` 的版本差异只在兼容层内消化,业务代码对版本无感知。 + +版本窗口 +-------- +依赖约束 ``google-adk>=1.34.0,<3.0.0``: + +- **1.34.x** 是最低支持锚点(不承诺 1.x 全系;1.0 缺 ``google.adk.apps`` 等,代价过大)。 +- **2.x**(当前 2.5.x)是上界内最新主线。 + +实测(2026-07-21, goal-00 探测,解包对比 1.34.3 vs 2.5.0):ksadk 用到的 +全部符号在两个版本**都存在**,差异仅为 2.x 的**向后兼容可选新增**: + +- ``Runner.run_async`` 2.x 新增可选 ``yield_user_message``(默认 False)。 +- ``RunConfig`` 2.x 新增若干可选字段(``http_options`` / ``telemetry`` / + ``model_input_context`` 等),均为可选,不传不影响。 +- ``App.root_agent`` 2.x 变为可选(1.34 必填);ksadk 始终显式传,两版皆可。 +- ``DatabaseSessionService`` 2.x 新增可选 ``db_engine``(``db_url`` 变可选); + ksadk 始终传 ``db_url``,两版皆可。 +- ``McpToolset.header_provider`` 2.x 支持 awaitable;ksadk 未用该参数。 + +ksadk 现有调用**不依赖任何 2.x-only 参数**,因此 1.34 与 2.x 对 ksadk 能力对等, +无需降级 shim。能力降级矩阵的完整文字版见 +``docs/adk-multi-version-compat.md``。 + +使用 2.x-only 新能力的纪律 +-------------------------- +若未来要用某个 2.x 才有的可选参数/符号,**必须**在本层用 +:func:`adk_version_at_least` 判断后再下发,并对 1.34 做显式降级;不允许业务 +代码自行 ``import google.adk`` 探测。 + +加载语义 +-------- +本模块 import 自身**不触发** ``google.adk`` 导入(惰性,PEP 562 +``__getattr__``)。只有真正访问某个符号时才导入对应 ``google.adk`` 子模块; +未安装 ``google-adk`` 时抛出带安装提示的 :class:`ImportError`,便于 +``memory/adk_tool`` 等调用方捕获并降级为原函数。 +""" + +from __future__ import annotations + +from importlib import import_module +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _dist_version +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - 仅供 mypy 静态检查,运行时不执行 + pass + +#: 导出符号名 -> 提供该符号的 google.adk 子模块路径。 +#: 这是 ksadk 全部 google.adk 依赖的唯一登记表;新增依赖先在这里登记。 +_LAZY_SYMBOLS: dict[str, str] = { + # agents + "Agent": "google.adk.agents", + "RunConfig": "google.adk.agents.run_config", + "StreamingMode": "google.adk.agents.run_config", + # apps + "App": "google.adk.apps", + "ResumabilityConfig": "google.adk.apps", + # events + "Event": "google.adk.events.event", + # memory + "BaseMemoryService": "google.adk.memory.base_memory_service", + "SearchMemoryResponse": "google.adk.memory.base_memory_service", + "MemoryEntry": "google.adk.memory.memory_entry", + # models + "LlmResponse": "google.adk.models", + "LiteLlm": "google.adk.models.lite_llm", + # runners + "Runner": "google.adk.runners", + # sessions + "BaseSessionService": "google.adk.sessions", + "InMemorySessionService": "google.adk.sessions", + "Session": "google.adk.sessions", + "DatabaseSessionService": "google.adk.sessions", + "InvocationContext": "google.adk.agents", + "ToolContext": "google.adk.tools", + "GetSessionConfig": "google.adk.sessions.base_session_service", + "ListSessionsResponse": "google.adk.sessions.base_session_service", + # tools + "FunctionTool": "google.adk.tools", + "load_memory": "google.adk.tools", + "McpTool": "google.adk.tools.mcp_tool.mcp_tool", + "McpToolset": "google.adk.tools.mcp_tool.mcp_toolset", + "CheckableMcpHttpClientFactory": "google.adk.tools.mcp_tool.mcp_session_manager", + "StreamableHTTPConnectionParams": "google.adk.tools.mcp_tool.mcp_session_manager", +} + +#: 以模块形式导出的名字 -> 模块路径(如 genai types、需要 monkeypatch 的 lite_llm)。 +_LAZY_MODULES: dict[str, str] = { + "genai_types": "google.genai.types", +} + +_INSTALL_HINT = ( + "google-adk 未安装或版本不在支持窗口 (>=1.34.0,<3.0.0)。" "安装: pip install 'ksadk[adk]'" +) + +_ADK_NOT_FOUND = "google-adk 未安装" + + +def _raise_missing(name: str, exc: BaseException) -> None: + raise ImportError(f"无法导入 google.adk 符号 {name!r}。{_INSTALL_HINT}") from exc + + +def __getattr__(name: str) -> Any: + """PEP 562 惰性解析:首次访问符号时才导入对应 google.adk 子模块。""" + if name in _LAZY_SYMBOLS: + module_path = _LAZY_SYMBOLS[name] + try: + module = import_module(module_path) + except ImportError as exc: + _raise_missing(name, exc) + try: + value = getattr(module, name) + except AttributeError as exc: + raise ImportError( + f"google.adk 已安装但 {module_path} 缺少 {name!r};" + f"当前版本 {adk_version() or '未知'} 可能不在支持窗口 (>=1.34.0,<3.0.0)。" + ) from exc + # 注意: 刻意不缓存到 globals()。测试常 monkeypatch ``google.adk.*`` + # 子模块符号(如把 Runner 换成 FakeRunner);若在此缓存,会把某次 patch + # 后的值泄漏给后续解析,造成跨测试污染。每次经 getattr 重新解析, + # 让既有 ``google.adk.*`` patch 点继续生效。开销可忽略(import_module + # 命中 sys.modules 缓存)。 + return value + if name in _LAZY_MODULES: + module_path = _LAZY_MODULES[name] + try: + value = import_module(module_path) + except ImportError as exc: + _raise_missing(name, exc) + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_SYMBOLS) | set(_LAZY_MODULES)) + + +def is_adk_available() -> bool: + """google-adk 是否可导入(不关心版本)。""" + try: + import_module("google.adk") + except ImportError: + return False + return True + + +def adk_version() -> str | None: + """返回已安装的 google-adk 版本;未安装返回 None。""" + try: + return _dist_version("google-adk") + except PackageNotFoundError: + return None + + +def adk_version_at_least(min_version: str) -> bool: + """已安装 google-adk 版本是否 >= ``min_version``;未安装或无法解析返回 False。 + + 用于下发 2.x-only 能力前的版本门禁,例如:: + + if adk_version_at_least("2.0.0"): + kwargs["yield_user_message"] = True + """ + ver = adk_version() + if ver is None: + return False + try: + from packaging.version import Version + + return bool(Version(ver) >= Version(min_version)) + except Exception: + return False + + +def lite_llm_module() -> Any: + """返回 ``google.adk.models.lite_llm`` 模块对象。 + + adk_runner 的 JSON 容错 patch 需要 MonkeyPatch 该模块内的 + ``_message_to_generate_content_response``(私有函数),因此需要模块本身 + 而非单个符号。未安装 litellm/google-adk 时抛 :class:`ImportError`。 + """ + try: + return import_module("google.adk.models.lite_llm") + except ImportError as exc: + _raise_missing("google.adk.models.lite_llm", exc) + + +__all__ = [ + # 版本/能力探测 + "is_adk_available", + "adk_version", + "adk_version_at_least", + "lite_llm_module", + # 惰性符号 + *_LAZY_SYMBOLS.keys(), + *_LAZY_MODULES.keys(), +] diff --git a/ksadk/compat/copilotkit_a2ui.py b/ksadk/compat/copilotkit_a2ui.py new file mode 100644 index 00000000..c5a3b2ea --- /dev/null +++ b/ksadk/compat/copilotkit_a2ui.py @@ -0,0 +1,330 @@ +"""Compatibility bridge for the pinned CopilotKit and AG-UI A2UI packages. + +The currently pinned public packages disagree on the A2UI factory signature. +More importantly, the released LangGraph adapter moves an async model invocation +to a worker thread and calls it with ``asyncio.run``. Async providers such as +``ChatOpenAI`` retain event-loop-bound clients, so that path can wait forever. + +This module keeps CopilotKit's middleware lifecycle and dynamic-tool dispatch, +but owns the small framework glue that invokes the A2UI sub-agent on the active +event loop. The A2UI wire format, prompt construction, and validation are still +provided by the official AG-UI toolkit. +""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import os +from importlib import import_module +from typing import Any + +try: + from copilotkit import CopilotKitMiddleware, copilotkit_lg_middleware + from langchain.tools import ToolRuntime, tool + from langchain_core.messages import SystemMessage +except ImportError as exc: # The module itself belongs to the optional AG-UI feature. + raise ImportError( + "CopilotKit A2UI requires ksadk[agui] with copilotkit and ag-ui-langgraph installed." + ) from exc + +logger = logging.getLogger(__name__) + +_DEFAULT_GENERATION_TIMEOUT_SECONDS = 20.0 + +# The upstream toolkit's generic default prompt is intentionally exhaustive. +# It is also large enough to exceed the Hosted UI model gateway's reliable tool +# latency. This compact contract covers the v0.9 basic catalog used by ksadk-web +# while leaving applications free to pass their own guidelines explicitly. +_KSADK_HOSTED_UI_GUIDELINES = { + "generation_guidelines": """\ +Return exactly one render_a2ui tool call. Use A2UI v0.9 flat components: every +component has an id and a component field. The root id is root. Use only Text, +Column, Row, Card, List, Button, ChoicePicker, Divider, TextField, CheckBox, +or Slider. Put properties directly on each component, never under type or +properties. Use child for one component id and children for an array of ids. +For example: [{\"id\":\"root\",\"component\":\"Card\",\"child\":\"content\"}, +{\"id\":\"content\",\"component\":\"Column\",\"children\":[\"title\"]}, +{\"id\":\"title\",\"component\":\"Text\",\"variant\":\"h2\",\"text\":\"Status\"}]. +Use only facts present in the conversation and tool results. Do not return +Markdown or prose instead of the tool call.""", + "design_guidelines": """\ +Create one compact, readable surface. Use hierarchy and spacing through Card, +Column, Row, Text, Divider, and ChoicePicker instead of decorative elements. +For status, summarize real values; for risks, show severity and owner; for +choices, use ChoicePicker with a bound data value.""", +} + + +def _a2ui_toolkit_dependencies() -> tuple[Any, ...]: + """Resolve the optional toolkit, which currently publishes no type metadata.""" + try: + toolkit = import_module("ag_ui_a2ui_toolkit") + except ImportError as exc: + raise ImportError( + "CopilotKit A2UI requires ksadk[agui] with ag-ui-a2ui-toolkit installed." + ) from exc + names = ( + "RENDER_A2UI_TOOL_DEF", + "resolve_a2ui_tool_params", + "prepare_a2ui_request", + "build_a2ui_envelope", + "validate_a2ui_components", + "augment_prompt_with_validation_errors", + "wrap_error_envelope", + "MAX_A2UI_ATTEMPTS", + ) + values = tuple(getattr(toolkit, name, None) for name in names) + if any(value is None for value in values): + raise RuntimeError("ag-ui-a2ui-toolkit is missing a required A2UI integration symbol") + return values + + +( + _RENDER_A2UI_TOOL_DEF, + _resolve_a2ui_tool_params, + _prepare_a2ui_request, + _build_a2ui_envelope, + _validate_a2ui_components, + _augment_prompt_with_validation_errors, + _wrap_error_envelope, + _MAX_A2UI_ATTEMPTS, +) = _a2ui_toolkit_dependencies() + + +def _generation_timeout_seconds() -> float: + """Read the operator-owned A2UI deadline, with a conservative default.""" + raw = os.getenv("KSADK_A2UI_GENERATION_TIMEOUT_SECONDS", "").strip() + if not raw: + return _DEFAULT_GENERATION_TIMEOUT_SECONDS + try: + return max(1.0, min(float(raw), 120.0)) + except ValueError: + return _DEFAULT_GENERATION_TIMEOUT_SECONDS + + +def _tool_call_args(response: Any) -> dict[str, Any] | None: + for call in getattr(response, "tool_calls", None) or []: + if not isinstance(call, dict): + continue + if call.get("name") != _RENDER_A2UI_TOOL_DEF["function"]["name"]: + continue + args = call.get("args") + return args if isinstance(args, dict) else {} + return None + + +async def _invoke_a2ui_subagent( + model_with_tool: Any, + *, + prompt: str, + messages: list[Any], + timeout_seconds: float, +) -> dict[str, Any] | None: + """Ask the structured-output model on the same loop as the parent run.""" + + async def invoke() -> Any: + ainvoke = getattr(model_with_tool, "ainvoke", None) + if callable(ainvoke): + result = ainvoke([SystemMessage(content=prompt), *messages]) + return await result if inspect.isawaitable(result) else result + invoke_sync = getattr(model_with_tool, "invoke", None) + if not callable(invoke_sync): + raise TypeError("A2UI model must expose ainvoke() or invoke()") + return await asyncio.to_thread(invoke_sync, [SystemMessage(content=prompt), *messages]) + + response = await asyncio.wait_for(invoke(), timeout=timeout_seconds) + return _tool_call_args(response) + + +def _attempt_callback(callback: Any, record: dict[str, Any]) -> None: + if not callable(callback): + return + try: + callback(record) + except Exception: + # Diagnostics must not prevent a usable UI surface from being returned. + logger.debug("A2UI attempt callback failed", exc_info=True) + + +async def _generate_a2ui_envelope( + *, + config: dict[str, Any], + state: dict[str, Any], + messages: list[Any], + intent: str, + target_surface_id: str | None, + changes: str | None, +) -> str: + """Run the official validate/retry contract without crossing event loops.""" + + prep = _prepare_a2ui_request( + intent=intent, + target_surface_id=target_surface_id, + changes=changes, + messages=messages, + state=state, + guidelines=config["guidelines"], + ) + if prep.get("error"): + return str(_wrap_error_envelope(prep["error"])) + + model = config["model"] + model_with_tool = model.bind_tools( + [_RENDER_A2UI_TOOL_DEF], + tool_choice=_RENDER_A2UI_TOOL_DEF["function"]["name"], + ) + recovery = config.get("recovery") or {} + max_attempts = recovery.get("maxAttempts", _MAX_A2UI_ATTEMPTS) + try: + max_attempts = max(1, min(int(max_attempts), 5)) + except (TypeError, ValueError): + max_attempts = _MAX_A2UI_ATTEMPTS + + timeout_seconds = _generation_timeout_seconds() + last_errors: list[dict[str, str]] = [] + attempts: list[dict[str, Any]] = [] + for attempt in range(1, max_attempts + 1): + prompt = _augment_prompt_with_validation_errors(prep["prompt"], last_errors) + try: + args = await _invoke_a2ui_subagent( + model_with_tool, + prompt=prompt, + messages=messages, + timeout_seconds=timeout_seconds, + ) + except asyncio.TimeoutError: + record: dict[str, Any] = { + "attempt": attempt, + "ok": False, + "errors": [ + { + "code": "a2ui_generation_timeout", + "path": "model", + "message": f"A2UI generation exceeded {timeout_seconds:g} seconds", + } + ], + } + attempts.append(record) + _attempt_callback(config.get("on_a2ui_attempt"), record) + # Retrying an already-cancelled request usually piles up more work; + # return a visible error instead of leaving the chat in limbo. + return str(_wrap_error_envelope(record["errors"][0]["message"])) + except Exception as exc: + record = { + "attempt": attempt, + "ok": False, + "errors": [ + { + "code": "a2ui_generation_error", + "path": "model", + "message": str(exc) or type(exc).__name__, + } + ], + } + attempts.append(record) + _attempt_callback(config.get("on_a2ui_attempt"), record) + return str(_wrap_error_envelope(record["errors"][0]["message"])) + + if not args: + record = { + "attempt": attempt, + "ok": False, + "errors": [ + { + "code": "empty_components", + "path": "components", + "message": "Sub-agent did not call render_a2ui", + } + ], + } + else: + components = args.get("components") + data = args.get("data") + validation = _validate_a2ui_components( + components=components if isinstance(components, list) else [], + data=data if isinstance(data, dict) else {}, + catalog=config.get("catalog"), + ) + record = { + "attempt": attempt, + "ok": bool(validation["valid"]), + "errors": list(validation["errors"]), + } + attempts.append(record) + _attempt_callback(config.get("on_a2ui_attempt"), record) + if record["ok"]: + return str( + _build_a2ui_envelope( + args=args, + is_update=prep["is_update"], + target_surface_id=target_surface_id, + prior=prep["prior"], + default_surface_id=config["default_surface_id"], + default_catalog_id=config["default_catalog_id"], + ) + ) + last_errors = record["errors"] + + return str( + _wrap_error_envelope(f"Failed to generate valid A2UI after {max_attempts} attempt(s)") + ) + + +def build_ksadk_a2ui_tool(params: dict[str, Any]) -> Any: + """Build the dynamic LangChain A2UI tool used by the CopilotKit shim.""" + + resolved_params = dict(params) + resolved_params.setdefault("guidelines", _KSADK_HOSTED_UI_GUIDELINES) + config = _resolve_a2ui_tool_params(resolved_params) + + @tool(config["tool_name"], description=config["tool_description"]) + async def generate_a2ui( + runtime: ToolRuntime[Any], + intent: str = "create", + target_surface_id: str | None = None, + changes: str | None = None, + ) -> str: + state = runtime.state if isinstance(runtime.state, dict) else {} + messages = list(state.get("messages") or [])[:-1] + return await _generate_a2ui_envelope( + config=config, + state=state, + messages=messages, + intent=intent, + target_surface_id=target_surface_id, + changes=changes, + ) + + return generate_a2ui + + +class KsadkCopilotKitMiddleware(CopilotKitMiddleware): + """Official lifecycle middleware with KSADK's safe A2UI tool executor.""" + + def _maybe_build_a2ui_tool(self, request: Any) -> Any | None: + state = request.state or {} + if not self._a2ui_inject_decision(state): + return None + + resolved = self._resolve_a2ui_catalog(state) + _component_schema, catalog_id = resolved if resolved else (None, None) + params: dict[str, Any] = {"model": request.model} + if catalog_id: + params["default_catalog_id"] = catalog_id + + tool = build_ksadk_a2ui_tool(params) + existing_names = {getattr(item, "name", None) for item in (request.tools or [])} + if tool.name in existing_names: + return None + + thread_key = ( + copilotkit_lg_middleware._current_thread_id() + or copilotkit_lg_middleware._DEFAULT_THREAD_KEY + ) + copilotkit_lg_middleware._a2ui_tools_by_thread[thread_key] = tool + return tool + + +__all__ = ["KsadkCopilotKitMiddleware", "build_ksadk_a2ui_tool"] diff --git a/ksadk/configs/__init__.py b/ksadk/configs/__init__.py index 729cf76b..d8762f19 100644 --- a/ksadk/configs/__init__.py +++ b/ksadk/configs/__init__.py @@ -5,7 +5,7 @@ 使用方式: from ksadk.configs import settings - + 环境变量优先级 (举例): Model: OPENAI_API_KEY > LLM_API_KEY > MODEL_API_KEY Langfuse: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, LANGFUSE_HOST @@ -17,24 +17,24 @@ """ from ksadk.configs.settings import ( - # 全局配置入口 - settings, - Settings, - # 各配置类 - ModelConfig, - LangfuseConfig, + DEFAULT_MODEL_NAME, + KSPMAS_INTERNAL_HOST, + KSPMAS_INTERNAL_URL, + KSPMAS_PUBLIC_URL, AgentConfig, - KingsoftCloudConfig, CodeModeConfig, + KingsoftCloudConfig, + LangfuseConfig, + # 各配置类 + ModelConfig, OTelConfig, + Settings, # 通用网络检测工具 check_endpoint_reachable, # KSPMAS 服务 get_kspmas_api_base, - KSPMAS_INTERNAL_HOST, - KSPMAS_INTERNAL_URL, - KSPMAS_PUBLIC_URL, - DEFAULT_MODEL_NAME, + # 全局配置入口 + settings, setup_environment, ) diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index b5f70f5b..3d69114d 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -16,9 +16,51 @@ class EnvVarSpec: EnvVarSpec("KSADK_ADK_RESUMABLE", "runners", "Enable ADK invocation resume support.", "false"), EnvVarSpec("KSADK_ADK_SESSION_BACKEND", "sessions", "ADK-native session backend selector."), EnvVarSpec("KSADK_ADK_SESSION_PATH", "sessions", "ADK-native SQLite session database path."), - EnvVarSpec("KSADK_ADK_SESSION_URL", "sessions", "ADK-native database session URL.", sensitive=True), - EnvVarSpec("KSADK_AICP_ENDPOINT_MODE", "platform", "AICP endpoint selection mode: auto, detect, inner, or public."), - EnvVarSpec("KSADK_ALLOWED_SUFFIXES", "builders", "Internal code package allowed suffix constant."), + EnvVarSpec( + "KSADK_ADK_SESSION_URL", "sessions", "ADK-native database session URL.", sensitive=True + ), + EnvVarSpec( + "KSADK_A2A_CONTROL_PLANE_URL", + "a2a", + "AgentEngine A2A runtime control-plane base URL injected by the deploy layer.", + ), + EnvVarSpec( + "KSADK_A2A_ENABLE_PUBLIC_EGRESS", + "a2a", + "Allow calling external (public-egress) agents in the A2A Space.", + "false when the deploy layer does not inject a value", + ), + EnvVarSpec( + "KSADK_A2A_EVENT_OUTBOX_PATH", + "a2a", + "SQLite path for durable A2A task-event delivery batches.", + ".agentengine/a2a_event_outbox.sqlite3", + ), + EnvVarSpec( + "KSADK_A2A_TOKEN_DIR", + "a2a", + "Directory containing audience-specific projected A2A workload JWT files.", + "/var/run/secrets/agentengine/a2a", + ), + EnvVarSpec( + "KSADK_A2A_SPACE_IDS", + "a2a", + "JSON array of A2A Space ids configured for this Runtime Agent.", + ), + EnvVarSpec( + "KSADK_A2UI_GENERATION_TIMEOUT_SECONDS", + "agui", + "A2UI structured-generation deadline in seconds; values are clamped to 1 through 120.", + "20", + ), + EnvVarSpec( + "KSADK_AICP_ENDPOINT_MODE", + "platform", + "AICP endpoint selection mode: auto, detect, inner, or public.", + ), + EnvVarSpec( + "KSADK_ALLOWED_SUFFIXES", "builders", "Internal code package allowed suffix constant." + ), EnvVarSpec( "KSADK_ATTACHMENT_OCR_RUNTIME_REQUIREMENTS", "builders", @@ -65,13 +107,42 @@ class EnvVarSpec: "Built-in tool profile selector, such as default or coding.", "default", ), - EnvVarSpec("KSADK_CHECKPOINT_BACKEND", "sessions", "LangGraph checkpoint backend selector.", "local"), + EnvVarSpec( + "KSADK_CHECKPOINT_BACKEND", "sessions", "LangGraph checkpoint backend selector.", "local" + ), EnvVarSpec("KSADK_CHECKPOINT_PATH", "sessions", "Local SQLite checkpoint database path."), - EnvVarSpec("KSADK_COMMAND_", "sandbox", "Internal prefix for command policy environment controls."), - EnvVarSpec("KSADK_COMMAND_CWD", "sandbox", "Current working directory exported to command policy checks."), - EnvVarSpec("KSADK_COMPACT_MICROCOMPACT_COLD_ROUNDS", "runtime", "Groups older than this many rounds are compacted by L3 microcompact.", "3"), - EnvVarSpec("KSADK_COMPACT_MICROCOMPACT_ENABLED", "runtime", "Enable L3 microcompact deterministic cold-group compression in compaction pipeline.", "true"), - EnvVarSpec("KSADK_COMPACT_SNIP_ENABLED", "runtime", "Enable L2 snip deterministic redundancy removal in compaction pipeline.", "true"), + EnvVarSpec( + "KSADK_CODEX_USE_PROXY", + "codex", + "Codex proxy override: 1 forces the local Responses-to-Chat proxy and " + "0 forces direct mode.", + ), + EnvVarSpec( + "KSADK_COMMAND_", "sandbox", "Internal prefix for command policy environment controls." + ), + EnvVarSpec( + "KSADK_COMMAND_CWD", + "sandbox", + "Current working directory exported to command policy checks.", + ), + EnvVarSpec( + "KSADK_COMPACT_MICROCOMPACT_COLD_ROUNDS", + "runtime", + "Groups older than this many rounds are compacted by L3 microcompact.", + "3", + ), + EnvVarSpec( + "KSADK_COMPACT_MICROCOMPACT_ENABLED", + "runtime", + "Enable L3 microcompact deterministic cold-group compression in compaction pipeline.", + "true", + ), + EnvVarSpec( + "KSADK_COMPACT_SNIP_ENABLED", + "runtime", + "Enable L2 snip deterministic redundancy removal in compaction pipeline.", + "true", + ), EnvVarSpec( "KSADK_CORE_RUNTIME_REQUIREMENTS", "builders", @@ -81,21 +152,54 @@ class EnvVarSpec: EnvVarSpec("KSADK_EVENTS_TABLE", "sessions", "Internal SQLite events table constant."), EnvVarSpec("KSADK_FEISHU_APP_ID", "cli", "Feishu helper app id used by OpenClaw diagnostics."), EnvVarSpec("KSADK_FEISHU_RESULT_PATH", "cli", "Feishu helper result file path."), - EnvVarSpec("KSADK_GLOBAL_CONFIG_ENV_KEYS", "cli", "Internal marker for env vars injected from global config."), + EnvVarSpec( + "KSADK_GLOBAL_CONFIG_ENV_KEYS", + "cli", + "Internal marker for env vars injected from global config.", + ), + EnvVarSpec( + "KSADK_HOSTED_UI_GUIDELINES", + "web", + "Internal hosted-A2UI guideline constant; not a supported environment override.", + ), EnvVarSpec("KSADK_KB", "knowledge_base", "AICP knowledge-base connection prefix."), - EnvVarSpec("KSADK_KB_ACCESS_KEY", "knowledge_base", "Knowledge-base API access key.", sensitive=True), + EnvVarSpec( + "KSADK_KB_ACCESS_KEY", "knowledge_base", "Knowledge-base API access key.", sensitive=True + ), EnvVarSpec("KSADK_KB_DATASET_ID", "knowledge_base", "Knowledge-base dataset id."), - EnvVarSpec("KSADK_KB_ENDPOINT", "knowledge_base", "Knowledge-base API endpoint.", "aicp.api.ksyun.com"), + EnvVarSpec( + "KSADK_KB_ENDPOINT", "knowledge_base", "Knowledge-base API endpoint.", "aicp.api.ksyun.com" + ), EnvVarSpec("KSADK_KB_REGION", "knowledge_base", "Knowledge-base region.", "cn-beijing-6"), - EnvVarSpec("KSADK_KB_RERANKING_ENABLE", "knowledge_base", "Enable knowledge-base reranking.", "false"), - EnvVarSpec("KSADK_KB_SCORE_THRESHOLD", "knowledge_base", "Knowledge-base score threshold.", "0.0"), - EnvVarSpec("KSADK_KB_SEARCH_METHOD", "knowledge_base", "Knowledge-base search method.", "intelligence_search"), - EnvVarSpec("KSADK_KB_SECRET_KEY", "knowledge_base", "Knowledge-base API secret key.", sensitive=True), + EnvVarSpec( + "KSADK_KB_RERANKING_ENABLE", "knowledge_base", "Enable knowledge-base reranking.", "false" + ), + EnvVarSpec( + "KSADK_KB_SCORE_THRESHOLD", "knowledge_base", "Knowledge-base score threshold.", "0.0" + ), + EnvVarSpec( + "KSADK_KB_SEARCH_METHOD", + "knowledge_base", + "Knowledge-base search method.", + "intelligence_search", + ), + EnvVarSpec( + "KSADK_KB_SECRET_KEY", "knowledge_base", "Knowledge-base API secret key.", sensitive=True + ), EnvVarSpec("KSADK_KB_TOP_K", "knowledge_base", "Knowledge-base retrieval result count.", "5"), - EnvVarSpec("KSADK_LANGGRAPH_CHECKPOINT_DSN", "sessions", "LangGraph PostgreSQL checkpoint DSN.", sensitive=True), - EnvVarSpec("KSADK_LOCAL_SKILLS_DIR", "skills", "Local directory containing extracted Skill packages."), + EnvVarSpec( + "KSADK_LANGGRAPH_CHECKPOINT_DSN", + "sessions", + "LangGraph PostgreSQL checkpoint DSN.", + sensitive=True, + ), + EnvVarSpec( + "KSADK_LOCAL_SKILLS_DIR", "skills", "Local directory containing extracted Skill packages." + ), EnvVarSpec("KSADK_LTM", "memory", "AICP long-term-memory connection prefix."), - EnvVarSpec("KSADK_LTM_ACCESS_KEY", "memory", "Long-term-memory API access key.", sensitive=True), + EnvVarSpec( + "KSADK_LTM_ACCESS_KEY", "memory", "Long-term-memory API access key.", sensitive=True + ), EnvVarSpec("KSADK_LTM_AGENT_ID", "memory", "Long-term-memory agent id."), EnvVarSpec("KSADK_LTM_APP_NAME", "memory", "Long-term-memory application name override."), EnvVarSpec( @@ -106,48 +210,126 @@ class EnvVarSpec: ), EnvVarSpec("KSADK_LTM_BACKEND", "memory", "Long-term-memory backend selector.", "local"), EnvVarSpec("KSADK_LTM_ENDPOINT", "memory", "Long-term-memory API endpoint."), - EnvVarSpec("KSADK_LTM_HTTP_TOKEN", "memory", "HTTP long-term-memory bearer token.", sensitive=True), - EnvVarSpec("KSADK_LTM_HTTP_URL", "memory", "HTTP long-term-memory service URL.", sensitive=True), + EnvVarSpec( + "KSADK_LTM_HTTP_TOKEN", "memory", "HTTP long-term-memory bearer token.", sensitive=True + ), + EnvVarSpec( + "KSADK_LTM_HTTP_URL", "memory", "HTTP long-term-memory service URL.", sensitive=True + ), EnvVarSpec("KSADK_LTM_INDEX", "memory", "Long-term-memory index name."), EnvVarSpec("KSADK_LTM_NAMESPACE", "memory", "Long-term-memory memory collection id."), EnvVarSpec("KSADK_LTM_REGION", "memory", "Long-term-memory region.", "cn-beijing-6"), EnvVarSpec("KSADK_LTM_SCENE_ID", "memory", "Long-term-memory scene id.", "_sys_general"), EnvVarSpec("KSADK_LTM_SCHEME", "memory", "Long-term-memory API scheme.", "https"), - EnvVarSpec("KSADK_LTM_SECRET_KEY", "memory", "Long-term-memory API secret key.", sensitive=True), + EnvVarSpec( + "KSADK_LTM_SECRET_KEY", "memory", "Long-term-memory API secret key.", sensitive=True + ), EnvVarSpec("KSADK_LTM_TOP_K", "memory", "Long-term-memory retrieval result count.", "5"), EnvVarSpec( "KSADK_MCP_RUNTIME_REQUIREMENTS", "builders", "Internal bundled MCP adapter runtime requirement constant.", ), - EnvVarSpec("KSADK_MCP_KEY", "mcp_runtime", "MCP service API key (also reused by ksyun web search provider).", sensitive=True), - EnvVarSpec("KSADK_MCP_SERVERS", "mcp_runtime", "JSON array of MCP server configs.", sensitive=True), + EnvVarSpec( + "KSADK_MCP_KEY", + "mcp_runtime", + "MCP service API key (also reused by ksyun web search provider).", + sensitive=True, + ), + EnvVarSpec( + "KSADK_MCP_SERVERS", "mcp_runtime", "JSON array of MCP server configs.", sensitive=True + ), EnvVarSpec("KSADK_MEMORY_BACKEND", "memory", "Generic memory backend selector.", "memory"), EnvVarSpec("KSADK_MEMORY_PREFIX", "memory", "Generic memory key prefix.", "ksadk:memory:"), EnvVarSpec("KSADK_MEMORY_TTL", "memory", "Generic memory default TTL seconds."), EnvVarSpec("KSADK_MEMORY_URL", "memory", "Generic memory backend URL.", sensitive=True), + EnvVarSpec( + "KSADK_MODEL_PROXY_AGENTS", + "model_proxy", + "Comma-separated agent allowlist for the experimental model proxy.", + ), + EnvVarSpec( + "KSADK_MODEL_PROXY_DENY", + "model_proxy", + "Comma-separated agent denylist that disables the model proxy.", + ), + EnvVarSpec( + "KSADK_MODEL_PROXY_ENABLED", + "model_proxy", + "Enable the experimental model proxy globally.", + "0", + ), + EnvVarSpec( + "KSADK_MODEL_PROXY_MODELS", + "model_proxy", + "Comma-separated model allowlist for the experimental model proxy.", + ), EnvVarSpec("KSADK_PG_EVENTS_TABLE", "sessions", "Internal PostgreSQL events table constant."), - EnvVarSpec("KSADK_PG_SESSIONS_TABLE", "sessions", "Internal PostgreSQL sessions table constant."), + EnvVarSpec( + "KSADK_PG_SESSIONS_TABLE", "sessions", "Internal PostgreSQL sessions table constant." + ), EnvVarSpec("KSADK_PG_STATES_TABLE", "sessions", "Internal PostgreSQL states table constant."), EnvVarSpec( "KSADK_POSTGRES_SESSION_REQUIREMENTS", "builders", "Internal bundled PostgreSQL session runtime requirement constant.", ), - EnvVarSpec("KSADK_PROJECT_DIR", "sessions", "Project root used for local session/workspace state."), - EnvVarSpec("KSADK_PUBLIC_SKILL_ALLOWLIST", "skills", "Comma-separated public Skill names to load; empty loads all public skills."), - EnvVarSpec("KSADK_PUBLIC_SKILL_SPACE_IDS", "skills", "Comma-separated public Skill Space ids appended after user spaces."), - EnvVarSpec("KSADK_RESPONSES_SESSION_HEADER", "runners", "Header name for remote Responses session propagation."), - EnvVarSpec("KSADK_RUNTIME_PORT", "cli", "Runtime HTTP port exported to template runtimes.", "8080"), - EnvVarSpec("KSADK_RUNTIME_REQUIREMENTS", "builders", "Internal bundled runtime requirements constant."), + EnvVarSpec( + "KSADK_PROJECT_DIR", "sessions", "Project root used for local session/workspace state." + ), + EnvVarSpec( + "KSADK_PROXY_TOKEN", "model_proxy", "Local Codex proxy bearer token.", sensitive=True + ), + EnvVarSpec( + "KSADK_PROXY_UPSTREAM_BASE", + "model_proxy", + "Override URL for the Codex proxy upstream provider.", + ), + EnvVarSpec( + "KSADK_PROXY_UPSTREAM_KEY", + "model_proxy", + "Override credential for the Codex proxy upstream provider.", + sensitive=True, + ), + EnvVarSpec( + "KSADK_PUBLIC_SKILL_ALLOWLIST", + "skills", + "Comma-separated public Skill names to load; empty loads all public skills.", + ), + EnvVarSpec( + "KSADK_PUBLIC_SKILL_SPACE_IDS", + "skills", + "Comma-separated public Skill Space ids appended after user spaces.", + ), + EnvVarSpec( + "KSADK_RESPONSES_SESSION_HEADER", + "runners", + "Header name for remote Responses session propagation.", + ), + EnvVarSpec( + "KSADK_RUNTIME_PORT", "cli", "Runtime HTTP port exported to template runtimes.", "8080" + ), + EnvVarSpec( + "KSADK_RUNTIME_REQUIREMENTS", "builders", "Internal bundled runtime requirements constant." + ), EnvVarSpec( "KSADK_ALLOW_POD_PROCESS_TOOLS", "sandbox", "Explicit opt-in required before pod_process sandbox tools are enabled.", "false", ), - EnvVarSpec("KSADK_MAX_TURNS", "runtime", "Maximum conversation turns before runtime circuit breaker opens.", "0"), - EnvVarSpec("KSADK_MAX_TOOL_CALLS", "runtime", "Maximum tool calls before runtime circuit breaker opens.", "0"), + EnvVarSpec( + "KSADK_MAX_TURNS", + "runtime", + "Maximum conversation turns before runtime circuit breaker opens.", + "0", + ), + EnvVarSpec( + "KSADK_MAX_TOOL_CALLS", + "runtime", + "Maximum tool calls before runtime circuit breaker opens.", + "0", + ), EnvVarSpec( "KSADK_MAX_CONSECUTIVE_TOOL_FAILURES", "runtime", @@ -169,106 +351,292 @@ class EnvVarSpec: EnvVarSpec( "KSADK_MAX_CONSECUTIVE_SEMANTIC_FAILURES", "runtime", - "Consecutive semantic LLM compaction failures before skipping semantic and using extractive; 0 disables the breaker (opt-in, default off to avoid permanent semantic disable from transient failures).", + "Consecutive semantic LLM compaction failures before using extractive; " + "0 disables the breaker to avoid permanent disable from transient failures.", "0", ), - EnvVarSpec("KSADK_SAFE_", "tools", "Internal prefix for tool safety policy environment controls."), - EnvVarSpec("KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", "sandbox", "Allow remote sandbox internet access.", "true"), + EnvVarSpec( + "KSADK_SAFE_", "tools", "Internal prefix for tool safety policy environment controls." + ), + EnvVarSpec( + "KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", + "sandbox", + "Allow remote sandbox internet access.", + "true", + ), EnvVarSpec("KSADK_SANDBOX_BACKEND", "sandbox", "Generic sandbox backend selector.", "e2b"), - EnvVarSpec("KSADK_SANDBOX_IDLE_TTL_SECONDS", "sandbox", "Idle TTL seconds before sandbox registry reclaims inactive sessions; 0 disables idle reclamation.", "300"), - EnvVarSpec("KSADK_SANDBOX_MAX_SESSIONS", "sandbox", "Maximum active sandbox registry sessions.", "0"), - EnvVarSpec("KSADK_SANDBOX_SESSION_ID", "sandbox", "Explicit sandbox registry session id override."), - EnvVarSpec("KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS", "sandbox", "Sandbox startup readiness retry attempts.", "6"), - EnvVarSpec("KSADK_SANDBOX_STARTUP_RETRY_DELAY", "sandbox", "Sandbox startup readiness initial retry delay seconds.", "0.2"), - EnvVarSpec("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", "sandbox", "Background sweep interval seconds for sandbox registry; 0 disables the background sweeper.", "60"), - EnvVarSpec("KSADK_SANDBOX_SYNC_MAX_FILE_BYTES", "sandbox", "Maximum single file size for workspace sync into sandbox."), - EnvVarSpec("KSADK_SANDBOX_SYNC_MAX_FILES", "sandbox", "Maximum file count for workspace sync into sandbox."), - EnvVarSpec("KSADK_SANDBOX_SYNC_MAX_TOTAL_BYTES", "sandbox", "Maximum total bytes for workspace sync into sandbox."), + EnvVarSpec( + "KSADK_SANDBOX_IDLE_TTL_SECONDS", + "sandbox", + "Idle TTL seconds before sandbox registry reclaims inactive sessions; " + "0 disables idle reclamation.", + "300", + ), + EnvVarSpec( + "KSADK_SANDBOX_MAX_SESSIONS", "sandbox", "Maximum active sandbox registry sessions.", "0" + ), + EnvVarSpec( + "KSADK_SANDBOX_SESSION_ID", "sandbox", "Explicit sandbox registry session id override." + ), + EnvVarSpec( + "KSADK_SANDBOX_STARTUP_RETRY_ATTEMPTS", + "sandbox", + "Sandbox startup readiness retry attempts.", + "6", + ), + EnvVarSpec( + "KSADK_SANDBOX_STARTUP_RETRY_DELAY", + "sandbox", + "Sandbox startup readiness initial retry delay seconds.", + "0.2", + ), + EnvVarSpec( + "KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", + "sandbox", + "Background sweep interval seconds for sandbox registry; " + "0 disables the background sweeper.", + "60", + ), + EnvVarSpec( + "KSADK_SANDBOX_SYNC_MAX_FILE_BYTES", + "sandbox", + "Maximum single file size for workspace sync into sandbox.", + ), + EnvVarSpec( + "KSADK_SANDBOX_SYNC_MAX_FILES", + "sandbox", + "Maximum file count for workspace sync into sandbox.", + ), + EnvVarSpec( + "KSADK_SANDBOX_SYNC_MAX_TOTAL_BYTES", + "sandbox", + "Maximum total bytes for workspace sync into sandbox.", + ), EnvVarSpec("KSADK_SANDBOX_TEMPLATE_ID", "sandbox", "Sandbox console template id."), EnvVarSpec("KSADK_SANDBOX_TIMEOUT", "sandbox", "Sandbox session timeout seconds.", "600"), - EnvVarSpec("KSADK_SANDBOX_TTL_SECONDS", "sandbox", "Hard TTL seconds for sandbox registry sessions.", "900"), - EnvVarSpec("KSADK_SANDBOX_TYPE", "sandbox", "Sandbox type: aio, code, browser, or private.", "aio"), - EnvVarSpec("KSADK_SELECTED_SKILL_NAMES", "skills", "Comma-separated Skill names selected by the outer agent."), + EnvVarSpec( + "KSADK_SANDBOX_TTL_SECONDS", + "sandbox", + "Hard TTL seconds for sandbox registry sessions.", + "900", + ), + EnvVarSpec( + "KSADK_SANDBOX_TYPE", "sandbox", "Sandbox type: aio, code, browser, or private.", "aio" + ), + EnvVarSpec( + "KSADK_SELECTED_SKILL_NAMES", + "skills", + "Comma-separated Skill names selected by the outer agent.", + ), EnvVarSpec("KSADK_SESSIONS_TABLE", "sessions", "Internal SQLite sessions table constant."), - EnvVarSpec("KSADK_SESSION_BACKEND", "sessions", "Conversation session backend selector.", "local"), - EnvVarSpec("KSADK_SESSION_CONNECT_TIMEOUT", "sessions", "Conversation PostgreSQL connection timeout seconds.", "5"), - EnvVarSpec("KSADK_SESSION_DSN", "sessions", "Conversation session database DSN.", sensitive=True), + EnvVarSpec( + "KSADK_SESSION_BACKEND", "sessions", "Conversation session backend selector.", "local" + ), + EnvVarSpec( + "KSADK_SESSION_CONNECT_TIMEOUT", + "sessions", + "Conversation PostgreSQL connection timeout seconds.", + "5", + ), + EnvVarSpec( + "KSADK_SESSION_DSN", "sessions", "Conversation session database DSN.", sensitive=True + ), EnvVarSpec("KSADK_SESSION_NAMESPACE", "sessions", "Conversation session namespace."), EnvVarSpec("KSADK_SESSION_PATH", "sessions", "Conversation local SQLite database path."), - EnvVarSpec("KSADK_SESSION_PG_CONNECT_TIMEOUT", "sessions", "Legacy PostgreSQL session connection timeout seconds.", "5"), - EnvVarSpec("KSADK_SKILLS_MODE", "skills", "Skill loading mode: auto, local, or sandbox.", "auto"), + EnvVarSpec( + "KSADK_SESSION_PG_CONNECT_TIMEOUT", + "sessions", + "Legacy PostgreSQL session connection timeout seconds.", + "5", + ), + EnvVarSpec( + "KSADK_SKILLS_MODE", "skills", "Skill loading mode: auto, local, or sandbox.", "auto" + ), EnvVarSpec( "KSADK_SKILL_ALLOW_HASH_MISMATCH", "skills", "Allow loading legacy Skill archives when ContentHash verification fails.", "false", ), - EnvVarSpec("KSADK_SKILL_ARTIFACT_PROJECT", "skills", "Default artifact project name for the minimal Skill Runtime agent.", "ksadk-artifact"), - EnvVarSpec("KSADK_SKILL_CACHE_DIR", "skills", "Skill package download and extraction cache directory."), - EnvVarSpec("KSADK_SKILL_MANIFEST_LIMIT", "skills", "Maximum remote Skill manifests injected into agent instructions.", "30"), - EnvVarSpec("KSADK_SKILL_MANIFEST_TIMEOUT", "skills", "Remote Skill manifest listing timeout seconds.", "5"), - EnvVarSpec("KSADK_SKILL_OUTPUT_DIR", "skills", "Output directory exposed to local Skill workflow scripts."), - EnvVarSpec("KSADK_SKILL_ROOT_DIR", "skills", "Root directory of the Skill currently executed by the local workflow runner."), - EnvVarSpec("KSADK_SKILL_RUNTIME_AGENT_PATH", "skills", "Local process Skill Runtime agent path."), - EnvVarSpec("KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS", "skills", "Allow remote Skill Runtime internet access.", "true"), - EnvVarSpec("KSADK_SKILL_RUNTIME_BACKEND", "skills", "Skill Runtime backend selector.", "disabled"), + EnvVarSpec( + "KSADK_SKILL_ARTIFACT_PROJECT", + "skills", + "Default artifact project name for the minimal Skill Runtime agent.", + "ksadk-artifact", + ), + EnvVarSpec( + "KSADK_SKILL_CACHE_DIR", "skills", "Skill package download and extraction cache directory." + ), + EnvVarSpec( + "KSADK_SKILL_MANIFEST_LIMIT", + "skills", + "Maximum remote Skill manifests injected into agent instructions.", + "30", + ), + EnvVarSpec( + "KSADK_SKILL_MANIFEST_TIMEOUT", + "skills", + "Remote Skill manifest listing timeout seconds.", + "5", + ), + EnvVarSpec( + "KSADK_SKILL_OUTPUT_DIR", + "skills", + "Output directory exposed to local Skill workflow scripts.", + ), + EnvVarSpec( + "KSADK_SKILL_ROOT_DIR", + "skills", + "Root directory of the Skill currently executed by the local workflow runner.", + ), + EnvVarSpec( + "KSADK_SKILL_RUNTIME_AGENT_PATH", "skills", "Local process Skill Runtime agent path." + ), + EnvVarSpec( + "KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS", + "skills", + "Allow remote Skill Runtime internet access.", + "true", + ), + EnvVarSpec( + "KSADK_SKILL_RUNTIME_BACKEND", "skills", "Skill Runtime backend selector.", "disabled" + ), EnvVarSpec("KSADK_SKILL_RUNTIME_TEMPLATE_ID", "skills", "Skill Runtime backend template id."), - EnvVarSpec("KSADK_SKILL_RUNTIME_TIMEOUT", "skills", "Skill Runtime workflow timeout seconds.", "900"), - EnvVarSpec("KSADK_SKILL_SERVICE", "skills", "Skill Service AICP connection environment prefix."), - EnvVarSpec("KSADK_SKILL_SERVICE_ACCESS_KEY", "skills", "Skill Service KOP access key.", sensitive=True), + EnvVarSpec( + "KSADK_SKILL_RUNTIME_TIMEOUT", "skills", "Skill Runtime workflow timeout seconds.", "900" + ), + EnvVarSpec( + "KSADK_SKILL_SERVICE", "skills", "Skill Service AICP connection environment prefix." + ), + EnvVarSpec( + "KSADK_SKILL_SERVICE_ACCESS_KEY", "skills", "Skill Service KOP access key.", sensitive=True + ), EnvVarSpec("KSADK_SKILL_SERVICE_ACCOUNT_ID", "skills", "Skill Service tenant account id."), - EnvVarSpec("KSADK_SKILL_SERVICE_API_VERSION", "skills", "Skill Service KOP API version.", "2024-06-12"), + EnvVarSpec( + "KSADK_SKILL_SERVICE_API_VERSION", "skills", "Skill Service KOP API version.", "2024-06-12" + ), EnvVarSpec("KSADK_SKILL_SERVICE_ENDPOINT", "skills", "Skill Service AICP endpoint override."), EnvVarSpec("KSADK_SKILL_SERVICE_REGION", "skills", "Skill Service KOP region.", "cn-beijing-6"), EnvVarSpec("KSADK_SKILL_SERVICE_SCHEME", "skills", "Skill Service AICP URL scheme override."), - EnvVarSpec("KSADK_SKILL_SERVICE_SECRET_KEY", "skills", "Skill Service KOP secret key.", sensitive=True), - EnvVarSpec("KSADK_SKILL_SERVICE_SIGN_SERVICE", "skills", "Skill Service KOP signing service.", "aicp"), - EnvVarSpec("KSADK_SKILL_SERVICE_TOKEN", "skills", "Skill Service bearer token.", sensitive=True), + EnvVarSpec( + "KSADK_SKILL_SERVICE_SECRET_KEY", "skills", "Skill Service KOP secret key.", sensitive=True + ), + EnvVarSpec( + "KSADK_SKILL_SERVICE_SIGN_SERVICE", "skills", "Skill Service KOP signing service.", "aicp" + ), + EnvVarSpec( + "KSADK_SKILL_SERVICE_TOKEN", "skills", "Skill Service bearer token.", sensitive=True + ), EnvVarSpec("KSADK_SKILL_SERVICE_URL", "skills", "Skill Service API base URL."), EnvVarSpec("KSADK_SKILL_SPACE_IDS", "skills", "Comma-separated Skill Space ids."), - EnvVarSpec("KSADK_SKILL_WORKDIR", "skills", "Working directory for the minimal Skill Runtime agent."), + EnvVarSpec( + "KSADK_SKILL_WORKDIR", "skills", "Working directory for the minimal Skill Runtime agent." + ), EnvVarSpec("KSADK_STATES_TABLE", "sessions", "Internal SQLite states table constant."), EnvVarSpec("KSADK_STM_BACKEND", "sessions", "Short-term-memory session backend selector."), EnvVarSpec("KSADK_STM_DB_PATH", "sessions", "Legacy short-term-memory SQLite path."), - EnvVarSpec("KSADK_STM_DB_URL", "sessions", "Legacy short-term-memory database URL.", sensitive=True), + EnvVarSpec( + "KSADK_STM_DB_URL", "sessions", "Legacy short-term-memory database URL.", sensitive=True + ), EnvVarSpec("KSADK_STM_PATH", "sessions", "Short-term-memory SQLite path."), EnvVarSpec("KSADK_STM_URL", "sessions", "Short-term-memory database URL.", sensitive=True), EnvVarSpec("KSADK_TENANT_ID", "sessions", "Tenant id used for session namespace scoping."), EnvVarSpec( "KSADK_TERMINAL_EXEC_SUBCOMMAND_ALLOWLIST", "terminal", - "Comma-separated remote terminal exec prefixes appended to the default allowlist; use * to allow all.", + "Comma-separated remote terminal exec prefixes appended to the default allowlist; " + "use * to allow all.", + ), + EnvVarSpec( + "KSADK_TOOL_APPROVAL_MODE", + "tools", + "Built-in tool approval mode: ask, risk, or full.", + "risk", + ), + EnvVarSpec( + "KSADK_TOOL_RESULT_DIR", "tools", "Directory used to persist oversized tool results." + ), + EnvVarSpec( + "KSADK_TOOL_RESULT_MAX_CHARS", + "tools", + "Maximum inline characters for budgeted tool outputs.", + ), + EnvVarSpec( + "KSADK_TOOL_RESULT_PERSIST_THRESHOLD_CHARS", + "tools", + "Character threshold for persisting tool outputs.", + ), + EnvVarSpec( + "KSADK_TOOL_RESULT_PREVIEW_CHARS", + "tools", + "Preview character count for persisted tool outputs.", ), - EnvVarSpec("KSADK_TOOL_APPROVAL_MODE", "tools", "Built-in tool approval mode: off or strict.", "off"), - EnvVarSpec("KSADK_TOOL_RESULT_DIR", "tools", "Directory used to persist oversized tool results."), - EnvVarSpec("KSADK_TOOL_RESULT_MAX_CHARS", "tools", "Maximum inline characters for budgeted tool outputs."), - EnvVarSpec("KSADK_TOOL_RESULT_PERSIST_THRESHOLD_CHARS", "tools", "Character threshold for persisting tool outputs."), - EnvVarSpec("KSADK_TOOL_RESULT_PREVIEW_CHARS", "tools", "Preview character count for persisted tool outputs."), EnvVarSpec("KSADK_UPDATED_AT", "configs", "Internal config update timestamp field."), EnvVarSpec("KSADK_VERSION", "configs", "Internal config version field."), - EnvVarSpec("KSADK_UI_BUNDLE_PATH", "web", "Custom agent UI bundle path relative to project root."), + EnvVarSpec( + "KSADK_UI_BUNDLE_PATH", "web", "Custom agent UI bundle path relative to project root." + ), EnvVarSpec("KSADK_UI_PATH", "web", "Custom agent UI mount path."), EnvVarSpec("KSADK_UI_PROFILE", "web", "Agent UI profile selector, such as builtin or custom."), EnvVarSpec("KSADK_UI_URL", "web", "External custom agent UI URL."), - EnvVarSpec("KSADK_WEB_CACHE_DIR", "web", "Directory used by hosted Web UI static asset sync cache."), - EnvVarSpec("KSADK_WEB_PACKAGE", "web", "KsADK Web npm package name.", "@kingsoftcloud/ksadk-web"), + EnvVarSpec( + "KSADK_WEB_CACHE_DIR", "web", "Directory used by hosted Web UI static asset sync cache." + ), + EnvVarSpec( + "KSADK_WEB_PACKAGE", "web", "KsADK Web npm package name.", "@kingsoftcloud/ksadk-web" + ), EnvVarSpec("KSADK_WEB_RELEASE_URL", "web", "Optional KsADK Web tarball URL fallback."), - EnvVarSpec("KSADK_WEB_SEARCH_API_KEY", "web", "HTTP web search provider API key.", sensitive=True), + EnvVarSpec( + "KSADK_WEB_SEARCH_API_KEY", "web", "HTTP web search provider API key.", sensitive=True + ), EnvVarSpec("KSADK_WEB_SEARCH_BASE_URL", "web", "HTTP web search provider base URL."), - EnvVarSpec("KSADK_WEB_SEARCH_PROVIDER", "web", "Web search provider selector, such as fake, http, or ksyun."), - EnvVarSpec("KSADK_WEB_SEARCH_SCOPE", "web", "ksyun provider search scope: webpage/document/scholar/podcast/video.", "webpage"), - EnvVarSpec("KSADK_WEB_SSRF_POLICY_JSON", "web", "JSON policy overrides for web_fetch SSRF checks."), + EnvVarSpec( + "KSADK_WEB_SEARCH_PROVIDER", + "web", + "Web search provider selector, such as fake, http, or ksyun.", + ), + EnvVarSpec( + "KSADK_WEB_SEARCH_SCOPE", + "web", + "ksyun provider search scope: webpage/document/scholar/podcast/video.", + "webpage", + ), + EnvVarSpec( + "KSADK_WEB_SSRF_POLICY_JSON", "web", "JSON policy overrides for web_fetch SSRF checks." + ), EnvVarSpec("KSADK_WEB_TARBALL_NAME", "web", "KsADK Web fallback tarball file name."), - EnvVarSpec("KSADK_WEB_VERSION", "web", "KsADK Web npm dist-tag or version.", "latest"), - EnvVarSpec("KSADK_WORKING_SET_MAX_FILES", "runtime", "Maximum recent files recorded in compaction working set metadata.", "5"), - EnvVarSpec("KSADK_USER_BACKEND_URL", "web", "User-facing backend URL used by hosted UI integrations."), - EnvVarSpec("KSADK_WORKFLOW_PROMPT", "skills", "Prompt text exposed to local Skill workflow scripts."), - EnvVarSpec("KSADK_WORKSPACE_ID", "sessions", "Workspace id used for session namespace scoping."), - EnvVarSpec("CLOUD_MONITOR_APP_KEY", "tracing", "CloudMonitor AppKey for optional OTLP ingestion.", sensitive=True), + EnvVarSpec( + "KSADK_WEB_VERSION", + "web", + "Published KsADK Web npm version used for a reproducible wheel build.", + "0.3.0", + ), + EnvVarSpec( + "KSADK_WORKING_SET_MAX_FILES", + "runtime", + "Maximum recent files recorded in compaction working set metadata.", + "5", + ), + EnvVarSpec( + "KSADK_USER_BACKEND_URL", "web", "User-facing backend URL used by hosted UI integrations." + ), + EnvVarSpec( + "KSADK_WORKFLOW_PROMPT", "skills", "Prompt text exposed to local Skill workflow scripts." + ), + EnvVarSpec( + "KSADK_WORKSPACE_ID", "sessions", "Workspace id used for session namespace scoping." + ), + 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.", + "Enable or disable the CloudMonitor Langfuse SDK callback; defaults to enabled " + "when keys and host are present.", ), EnvVarSpec( "CLOUD_MONITOR_LANGFUSE_HOST", @@ -290,12 +658,14 @@ class EnvVarSpec: EnvVarSpec( "CLOUD_MONITOR_OTLP_ENABLED", "tracing", - "Enable or disable the CloudMonitor OTLP exporter; defaults to enabled when endpoint and AppKey are present.", + "Enable or disable the CloudMonitor OTLP exporter; defaults to enabled when " + "endpoint and AppKey are present.", ), EnvVarSpec( "CLOUD_MONITOR_OTLP_ENDPOINT", "tracing", - "CloudMonitor generic OTLP HTTP endpoint; KsADK derives /v1/traces when no traces endpoint is set.", + "CloudMonitor generic OTLP HTTP endpoint; KsADK derives /v1/traces when no " + "traces endpoint is set.", ), EnvVarSpec( "CLOUD_MONITOR_OTLP_HEADERS", @@ -318,13 +688,43 @@ class EnvVarSpec: "tracing", "CloudMonitor traces protocol; takes precedence over the generic protocol.", ), - EnvVarSpec("OTEL_EXPORTER_OTLP_ENDPOINT", "tracing", "Generic OTLP HTTP endpoint used to derive the traces endpoint."), - EnvVarSpec("OTEL_EXPORTER_OTLP_HEADERS", "tracing", "Generic OTLP HTTP headers, comma-separated and URL-encoded.", sensitive=True), - EnvVarSpec("OTEL_EXPORTER_OTLP_PROTOCOL", "tracing", "Generic OTLP protocol; KsADK auto HTTP exporter supports http/protobuf."), - EnvVarSpec("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "tracing", "OTLP HTTP traces endpoint; takes precedence over the generic endpoint."), - EnvVarSpec("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "tracing", "OTLP HTTP traces headers; takes precedence over generic headers.", sensitive=True), - EnvVarSpec("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "tracing", "OTLP traces protocol; takes precedence over the generic protocol."), - EnvVarSpec("OTEL_RESOURCE_ATTRIBUTES", "tracing", "OpenTelemetry resource attributes in key=value comma-separated form."), + EnvVarSpec( + "OTEL_EXPORTER_OTLP_ENDPOINT", + "tracing", + "Generic OTLP HTTP endpoint used to derive the traces endpoint.", + ), + EnvVarSpec( + "OTEL_EXPORTER_OTLP_HEADERS", + "tracing", + "Generic OTLP HTTP headers, comma-separated and URL-encoded.", + sensitive=True, + ), + EnvVarSpec( + "OTEL_EXPORTER_OTLP_PROTOCOL", + "tracing", + "Generic OTLP protocol; KsADK auto HTTP exporter supports http/protobuf.", + ), + EnvVarSpec( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "tracing", + "OTLP HTTP traces endpoint; takes precedence over the generic endpoint.", + ), + EnvVarSpec( + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "tracing", + "OTLP HTTP traces headers; takes precedence over generic headers.", + sensitive=True, + ), + EnvVarSpec( + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "tracing", + "OTLP traces protocol; takes precedence over the generic protocol.", + ), + EnvVarSpec( + "OTEL_RESOURCE_ATTRIBUTES", + "tracing", + "OpenTelemetry resource attributes in key=value comma-separated form.", + ), EnvVarSpec("OTEL_SERVICE_NAME", "tracing", "OpenTelemetry service name."), EnvVarSpec( "KSADK_OTLP_MAX_EXPORT_BATCH_SIZE", diff --git a/ksadk/configs/global_config.py b/ksadk/configs/global_config.py index 429ae404..9085edc3 100644 --- a/ksadk/configs/global_config.py +++ b/ksadk/configs/global_config.py @@ -80,7 +80,8 @@ def load_global_config() -> Dict[str, Any]: try: # 使用 utf-8-sig 自动处理 BOM,确保 Windows 兼容性 with open(config_path, "r", encoding="utf-8-sig") as f: - return json.load(f) + payload = json.load(f) + return payload if isinstance(payload, dict) else {} except (json.JSONDecodeError, IOError): return {} diff --git a/ksadk/configs/settings.py b/ksadk/configs/settings.py index 618548d7..8df03f7d 100644 --- a/ksadk/configs/settings.py +++ b/ksadk/configs/settings.py @@ -6,32 +6,34 @@ 使用方式: from ksadk.configs import settings - + # 访问模型配置 print(settings.model.api_key) print(settings.model.api_base) - + # 访问 Langfuse 配置 print(settings.langfuse.public_key) - + # 访问 Agent 运行时信息 print(settings.agent.agent_id) """ -import os +import importlib import json import logging +import os import time -from typing import Optional, List, Dict, Any from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional from urllib.parse import urlsplit, urlunsplit logger = logging.getLogger(__name__) -def _get_env(*keys: str, default: str = None) -> Optional[str]: +def _get_env(*keys: str, default: Optional[str] = None) -> Optional[str]: """从多个环境变量 key 中获取第一个存在的值 - + 按优先级顺序匹配,支持别名兼容。 """ for key in keys: @@ -69,44 +71,45 @@ def _is_public_kspmas_url(url: Optional[str]) -> bool: def check_endpoint_reachable(host: str, port: int = 80, timeout: float = 1.0) -> bool: """检测指定端点是否可达 - + 通用函数,可用于检测任意服务的内网地址是否可达。 结果会被缓存,相同 host 只检测一次。 - + Args: host: 主机名或 IP 地址 port: 端口号 (默认 80) timeout: 超时时间 (默认 1.0 秒) - + Returns: True 如果可达, False 否则 - + Usage: # KSPMAS 内网检测 if check_endpoint_reachable("kspmas-internal.sdns.ksyun.com"): api_base = "http://kspmas-internal.sdns.ksyun.com/v1" - + # KS3 内网检测 if check_endpoint_reachable("ks3-cn-beijing-internal.ksyuncs.com"): ks3_endpoint = "http://ks3-cn-beijing-internal.ksyuncs.com" """ if host in _endpoint_cache: return _endpoint_cache[host] - + try: import socket + # 尝试建立 TCP 连接 (比 ping 更可靠,且不依赖 ICMP) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((host, port)) s.close() - + _endpoint_cache[host] = True logger.debug(f"Endpoint reachable: {host}:{port}") except (socket.timeout, socket.error, OSError): _endpoint_cache[host] = False logger.debug(f"Endpoint not reachable: {host}:{port}") - + return _endpoint_cache[host] @@ -125,13 +128,13 @@ def check_endpoint_reachable(host: str, port: int = 80, timeout: float = 1.0) -> def optimize_kspmas_url(url: str) -> str: """优化 KSPMAS URL - + 如果是 KSPMAS 的公网地址,且检测到内网可达(或在 Serverless 环境), 将其替换为内网地址以提高速度和稳定性。 """ if not url: return url - + parsed = urlsplit(url) if parsed.hostname != "kspmas.ksyun.com": return url @@ -147,13 +150,13 @@ def optimize_kspmas_url(url: str) -> str: if parsed.port: netloc = f"{netloc}:{parsed.port}" return urlunsplit(("http", netloc, parsed.path, parsed.query, parsed.fragment)) - + return url def get_kspmas_api_base() -> str: """获取 KSPMAS 模型服务的 API Base URL - + 自动检测内网可达性,优先使用内网地址。 **特殊逻辑**: 如果在 Serverless 托管环境 (AGENT_RUNTIME_ID 存在),强制使用内网地址。 """ @@ -164,7 +167,7 @@ def get_kspmas_api_base() -> str: # 2. 自动检测内网可达性 if check_endpoint_reachable(KSPMAS_INTERNAL_HOST): return KSPMAS_INTERNAL_URL - + # 3. 默认使用公网 return KSPMAS_PUBLIC_URL @@ -173,82 +176,83 @@ def get_kspmas_api_base() -> str: # 模型配置 (LLM) # ============================================================================= + @dataclass class ModelConfig: """模型配置 - + 标准环境变量 (优先): OPENAI_API_KEY - API Key (OpenAI 标准) OPENAI_BASE_URL - API Base URL (OpenAI 标准) OPENAI_MODEL_NAME - 模型名称 - + 通用格式: MODEL_NAME - 模型名称 - + Serverless 平台兼容: LLM_API_KEY - 模型 API Key LLM_API_BASE - 模型 API 地址 LLM_MODEL - 模型名称 - + 别名 (兼容): OPENAI_API_BASE - API Base URL 别名 - + 默认值: OPENAI_BASE_URL: 自动检测内网/外网,使用金山云模型服务 MODEL_NAME: glm-5.2 """ - + @property def api_key(self) -> Optional[str]: """获取模型 API Key""" return _get_env( - "OPENAI_API_KEY", # OpenAI 标准 (优先) - "LLM_API_KEY", # Serverless 平台兼容 - "MODEL_API_KEY", # 通用 + "OPENAI_API_KEY", # OpenAI 标准 (优先) + "LLM_API_KEY", # Serverless 平台兼容 + "MODEL_API_KEY", # 通用 ) - + @property def api_base(self) -> str: """获取模型 API Base URL - + 如果未配置,默认使用金山云模型服务地址(自动检测内网)。 如果已配置且为 KSPMAS 公网地址,也会尝试优化为内网地址。 """ configured = _get_env( - "OPENAI_BASE_URL", # OpenAI 标准 (优先) - "OPENAI_API_BASE", # OpenAI 别名 (兼容) - "LLM_API_BASE", # Serverless 平台兼容 - "MODEL_API_BASE", # 通用 + "OPENAI_BASE_URL", # OpenAI 标准 (优先) + "OPENAI_API_BASE", # OpenAI 别名 (兼容) + "LLM_API_BASE", # Serverless 平台兼容 + "MODEL_API_BASE", # 通用 ) - + if configured: return optimize_kspmas_url(configured) - + return get_kspmas_api_base() - + @property def model_name(self) -> str: """获取模型名称 - + 如果未配置,默认使用 glm-5.2。 """ configured = _get_env( - "OPENAI_MODEL_NAME", # OpenAI 格式 (优先) - "MODEL_NAME", # 通用 - "LLM_MODEL", # Serverless 平台兼容 + "OPENAI_MODEL_NAME", # OpenAI 格式 (优先) + "MODEL_NAME", # 通用 + "LLM_MODEL", # Serverless 平台兼容 ) return configured or DEFAULT_MODEL_NAME - + @property def is_configured(self) -> bool: """是否已配置 API Key""" return bool(self.api_key) - + @property def is_using_internal_network(self) -> bool: """是否使用内网地址""" return KSPMAS_INTERNAL_URL in self.api_base - + def to_dict(self) -> dict: """转换为字典 (用于 LiteLLM 等)""" result = {} @@ -263,56 +267,60 @@ def to_dict(self) -> dict: # 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" + 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 { @@ -326,14 +334,15 @@ def to_dict(self) -> dict: # Agent 配置 # ============================================================================= + @dataclass class AgentConfig: """Agent 配置 - + 标准环境变量 (OpenTelemetry 兼容): OTEL_SERVICE_NAME - 服务名称 (映射到 agent_name) OTEL_RESOURCE_ATTRIBUTES - 资源属性 (key=value 格式) - + 通用环境变量: AGENT_ID - Agent 唯一 ID AGENT_NAME - Agent 名称 @@ -341,70 +350,70 @@ class AgentConfig: USER_ID - 用户 ID ENVIRONMENT - 运行环境 (dev/staging/prod) VERSION - 版本号 - + Serverless 平台兼容: AGENT_RUNTIME_ID - Agent 唯一 ID AGENT_RUNTIME_NAME - Agent 名称 ACCOUNT_ID - 账户 ID (租户) """ - + @property def agent_id(self) -> Optional[str]: """Agent 唯一 ID""" return _get_env( - "AGENT_ID", # 通用 (优先) - "AGENT_RUNTIME_ID", # Serverless 平台兼容 + "AGENT_ID", # 通用 (优先) + "AGENT_RUNTIME_ID", # Serverless 平台兼容 ) - + @property def agent_name(self) -> Optional[str]: """Agent 名称""" return _get_env( - "AGENT_NAME", # 通用 (优先) - "AGENT_RUNTIME_NAME", # Serverless 平台兼容 - "OTEL_SERVICE_NAME", # OpenTelemetry 标准 + "AGENT_NAME", # 通用 (优先) + "AGENT_RUNTIME_NAME", # Serverless 平台兼容 + "OTEL_SERVICE_NAME", # OpenTelemetry 标准 ) - + @property def tenant_id(self) -> Optional[str]: """租户 ID""" return _get_env( - "TENANT_ID", # 通用 (优先) - "ACCOUNT_ID", # Serverless 平台兼容 + "TENANT_ID", # 通用 (优先) + "ACCOUNT_ID", # Serverless 平台兼容 ) - + @property def user_id(self) -> Optional[str]: """用户 ID""" return os.getenv("USER_ID") - + @property def session_id(self) -> Optional[str]: """会话 ID (用于 Langfuse Sessions 功能)""" return os.getenv("SESSION_ID") - + @property def environment(self) -> Optional[str]: """运行环境 (dev/staging/prod)""" return os.getenv("ENVIRONMENT") - + @property def version(self) -> Optional[str]: """Agent 版本""" return os.getenv("VERSION") - + @property def tags(self) -> Optional[List[str]]: """标签列表 (逗号分隔)""" tags_str = os.getenv("TAGS", "") tags = [t.strip() for t in tags_str.split(",") if t.strip()] return tags if tags else None - + @property def extra_metadata(self) -> Optional[Dict[str, Any]]: """额外的元数据""" extra = {} - + # 1. 解析 JSON 格式的 metadata extra_json = os.getenv("METADATA") if extra_json: @@ -412,7 +421,7 @@ def extra_metadata(self) -> Optional[Dict[str, Any]]: extra.update(json.loads(extra_json)) except json.JSONDecodeError: logger.warning(f"Invalid METADATA JSON: {extra_json}") - + # 2. 解析 OTEL_RESOURCE_ATTRIBUTES otel_attrs = os.getenv("OTEL_RESOURCE_ATTRIBUTES", "") if otel_attrs: @@ -421,21 +430,21 @@ def extra_metadata(self) -> Optional[Dict[str, Any]]: key, value = pair.split("=", 1) if key not in ("service.name", "service.version"): extra[key] = value - + return extra if extra else None - + @property 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 = {} + params: Dict[str, Any] = {} if self.session_id: params["session_id"] = self.session_id if self.user_id: @@ -445,7 +454,7 @@ def to_langfuse_params(self) -> dict: if self.version: params["version"] = self.version return params - + def to_langfuse_metadata(self) -> dict: """转换为 Langfuse metadata 字段""" metadata = {} @@ -466,28 +475,29 @@ def to_langfuse_metadata(self) -> dict: # 金山云配置 # ============================================================================= + @dataclass class KingsoftCloudConfig: """金山云 SDK 配置 - + 环境变量: KS_ACCESS_KEY_ID - Access Key ID KS_SECRET_ACCESS_KEY - Secret Access Key KS_REGION - 区域 (默认 cn-beijing-6) """ - + @property def access_key_id(self) -> Optional[str]: return os.getenv("KS_ACCESS_KEY_ID") - + @property def secret_access_key(self) -> Optional[str]: return os.getenv("KS_SECRET_ACCESS_KEY") - + @property def region(self) -> str: return os.getenv("KS_REGION", "cn-beijing-6") - + @property def is_configured(self) -> bool: """是否已配置""" @@ -498,23 +508,24 @@ def is_configured(self) -> bool: # Code 模式配置 # ============================================================================= + @dataclass class CodeModeConfig: """Code 模式配置 (Serverless 平台) - + 环境变量: PYTHONPATH - Python 模块路径 CODE_PATH - 代码目录 """ - + @property def python_path(self) -> Optional[str]: return os.getenv("PYTHONPATH") - + @property def code_path(self) -> Optional[str]: return os.getenv("CODE_PATH") - + @property def is_code_mode(self) -> bool: """是否为 Code 模式""" @@ -525,41 +536,42 @@ def is_code_mode(self) -> bool: # OpenTelemetry 配置 # ============================================================================= + @dataclass class OTelConfig: """OpenTelemetry 配置 (标准环境变量) - + 环境变量: OTEL_SERVICE_NAME - 服务名称 OTEL_RESOURCE_ATTRIBUTES - 资源属性 (key=value,key2=value2) OTEL_EXPORTER_OTLP_ENDPOINT - OTLP 导出端点 """ - + @property def service_name(self) -> Optional[str]: return os.getenv("OTEL_SERVICE_NAME") - + @property def resource_attributes(self) -> Optional[Dict[str, str]]: """解析 OTEL_RESOURCE_ATTRIBUTES""" attrs_str = os.getenv("OTEL_RESOURCE_ATTRIBUTES", "") if not attrs_str: return None - + attrs = {} for pair in attrs_str.split(","): if "=" in pair: key, value = pair.split("=", 1) attrs[key] = value return attrs if attrs else None - + @property def otlp_endpoint(self) -> Optional[str]: return _get_env( "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", ) - + @property def is_enabled(self) -> bool: """是否启用 OTLP 导出""" @@ -570,45 +582,52 @@ def is_enabled(self) -> bool: # 统一配置入口 # ============================================================================= + @dataclass class Settings: """统一配置入口 - + 使用方式: from ksadk.configs import 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()) - + # 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) otel: OTelConfig = field(default_factory=OTelConfig) - + def summary(self) -> str: """输出配置摘要""" lines = ["AgentEngine Configuration:"] - lines.append(f" Model: {'✅ Configured' if self.model.is_configured else '❌ Not configured'}") + 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'}") - - lines.append(f" Cloud: {'✅ Configured' if self.cloud.is_configured else '⚪ Not configured'}") - lines.append(f" CodeMode: {'✅ ' + self.code.code_path if self.code.is_code_mode else '⚪ Container mode'}") + + lines.append( + f" Cloud: {'✅ Configured' if self.cloud.is_configured else '⚪ Not configured'}" + ) + code_path = self.code.code_path + lines.append(f" CodeMode: {'✅ ' + code_path if code_path else '⚪ Container mode'}") lines.append(f" OTEL: {'✅ Enabled' if self.otel.is_enabled else '⚪ Disabled'}") return "\n".join(lines) - + def __repr__(self) -> str: return self.summary() @@ -624,32 +643,30 @@ def __repr__(self) -> str: DEFAULT_RUNTIME_TIMEZONE = "Asia/Shanghai" -def setup_environment(agent_path: "Path"): +def setup_environment(agent_path: Path | str): """加载环境变量并注入智能默认配置 - + 1. 加载项目根目录的 .env 文件 (override=True) 2. 注入 OPENAI_API_BASE (智能检测) 3. 注入 OPENAI_MODEL_NAME (默认值) """ import click - import os - from pathlib import Path + try: - from dotenv import load_dotenv + dotenv_module = importlib.import_module("dotenv") except ImportError: - # 如果没有安装 python-dotenv,则跳过 loading .env - load_dotenv = None - + dotenv_module = None + # 1. 加载 .env - if load_dotenv: + if dotenv_module is not None: # Ensure agent_path is a Path object if isinstance(agent_path, str): agent_path = Path(agent_path) - + env_file = agent_path / ".env" if env_file.exists(): # override=False: 保留通过 API/Serverless 平台注入的环境变量 (优先级高) - load_dotenv(env_file, override=False) + dotenv_module.load_dotenv(env_file, override=False) # 本地调试与托管运行时统一默认北京时间;用户通过 shell/.env/平台显式指定 TZ 时不覆盖。 os.environ.setdefault("TZ", DEFAULT_RUNTIME_TIMEZONE) @@ -696,7 +713,7 @@ def setup_environment(agent_path: "Path"): if not os.getenv("OPENAI_API_BASE"): os.environ["OPENAI_API_BASE"] = api_base click.echo(f"🔧 API Base: {click.style(api_base, fg='cyan')} (Auto-detected)") - + # Model Name if not os.getenv("OPENAI_MODEL_NAME"): model_name = settings.model.model_name @@ -704,3 +721,13 @@ def setup_environment(agent_path: "Path"): click.echo(f"🧠 Model: {click.style(model_name, fg='cyan')} (Default)") if not os.getenv("MODEL_NAME") and os.getenv("OPENAI_MODEL_NAME"): os.environ["MODEL_NAME"] = os.getenv("OPENAI_MODEL_NAME", "") + + # 4. 模型出口协议转换层(v2.2):gate 开启且模型在白名单时懒起进程内 ProxyServer, + # 把 OPENAI_BASE_URL 重定向到它。默认 gate 关,不重定向,历史路径零影响。 + # codex 不走此路(它走 AsyncCodexClient 的 v2.1 provider 注入)。 + try: + from ksadk.model_proxy.bootstrap import setup_proxy_redirect_if_enabled + + setup_proxy_redirect_if_enabled() + except Exception: # noqa: BLE001 代理可选,失败不影响主流程(默认关时本就不触发) + pass diff --git a/ksadk/conversations/__init__.py b/ksadk/conversations/__init__.py index 2afb3373..d45ffc81 100644 --- a/ksadk/conversations/__init__.py +++ b/ksadk/conversations/__init__.py @@ -1,5 +1,10 @@ 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, @@ -16,21 +21,18 @@ attachment_from_part, attachment_prompt_text, compact_attachment_for_session, - decode_inline_data, display_content_from_parts, extract_user_input_from_parts, normalize_kop_message_content, normalize_kop_messages, normalize_parts_content, normalize_responses_input, - resolve_attachment_storage_path, - resolve_uploads_dir, ) from ksadk.conversations.runtime import ( CompactionPlan, PreparedConversationTurn, - append_conversation_event, append_context_checkpoint_event, + append_conversation_event, append_reasoning_event, append_run_checkpoint_event, append_run_resume_event, @@ -43,8 +45,8 @@ ensure_conversation_session, extract_responses_resume_input, invoke_conversation_once, - prime_session_metadata_for_user_turn, preview_auto_compaction, + prime_session_metadata_for_user_turn, stream_conversation_turn, stream_responses_conversation_turn, ) diff --git a/ksadk/conversations/attachment_storage.py b/ksadk/conversations/attachment_storage.py index d2e5a4a9..5380d61c 100644 --- a/ksadk/conversations/attachment_storage.py +++ b/ksadk/conversations/attachment_storage.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import importlib import json import logging import mimetypes @@ -81,8 +82,7 @@ def _download_hosted_attachment(file_uri: str) -> AttachmentBytes | None: data=content.data, display_name=display_name, mime_type=( - _content_type_without_params(content.content_type) - or guess_mime_type(display_name) + _content_type_without_params(content.content_type) or guess_mime_type(display_name) ), ) @@ -222,7 +222,9 @@ def read(self, file_uri: str) -> AttachmentBytes | None: return AttachmentBytes( data=raw, display_name=str(metadata.get("display_name") or local_path.name), - mime_type=str(metadata.get("mime_type") or guess_mime_type(local_path.name)), + mime_type=str( + metadata.get("mime_type") or guess_mime_type(local_path.name) + ), local_path=local_path, ) @@ -272,16 +274,17 @@ async def _put_ks3_object( async def _read_ks3_object(self, *, bucket: str, object_key: str) -> bytes: return await asyncio.to_thread(self._read_ks3_object_sync, bucket, object_key) - def _put_ks3_object_sync(self, bucket: str, object_key: str, data: bytes, mime_type: str) -> None: + def _put_ks3_object_sync( + self, bucket: str, object_key: str, data: bytes, mime_type: str + ) -> None: if not bucket: raise ValueError("KS3 bucket is not available") - from ks3.connection import Connection - ak = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") sk = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") if not ak or not sk: raise ValueError("KS3 credentials are not configured") - conn = Connection(ak, sk, host=self._endpoint()) + connection_class = importlib.import_module("ks3.connection").Connection + conn = connection_class(ak, sk, host=self._endpoint()) ks3_bucket = self._ensure_bucket(conn, bucket) key = ks3_bucket.new_key(object_key) key.set_contents_from_string( @@ -292,17 +295,19 @@ def _put_ks3_object_sync(self, bucket: str, object_key: str, data: bytes, mime_t def _read_ks3_object_sync(self, bucket: str, object_key: str) -> bytes: if not bucket or not object_key: raise FileNotFoundError(object_key) - from ks3.connection import Connection - ak = os.getenv("KSYUN_ACCESS_KEY") or os.getenv("KS3_ACCESS_KEY") sk = os.getenv("KSYUN_SECRET_KEY") or os.getenv("KS3_SECRET_KEY") if not ak or not sk: raise ValueError("KS3 credentials are not configured") - conn = Connection(ak, sk, host=self._endpoint()) + connection_class = importlib.import_module("ks3.connection").Connection + conn = connection_class(ak, sk, host=self._endpoint()) key = conn.get_bucket(bucket).get_key(object_key) if key is None: raise FileNotFoundError(object_key) - return key.get_contents_as_string() + content = key.get_contents_as_string() + if not isinstance(content, bytes): + raise TypeError("KS3 object content must be bytes") + return content @staticmethod def _ensure_bucket(conn: Any, bucket_name: str): diff --git a/ksadk/conversations/attachments.py b/ksadk/conversations/attachments.py index 7a44173f..d903b13e 100644 --- a/ksadk/conversations/attachments.py +++ b/ksadk/conversations/attachments.py @@ -1,8 +1,8 @@ from __future__ import annotations import base64 +import importlib import io -import json import mimetypes import zipfile from pathlib import Path, PurePosixPath @@ -168,7 +168,9 @@ def resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: return None -def read_attachment_bytes(storage_path: Optional[Path], *, size_limit: Optional[int] = None) -> Optional[bytes]: +def read_attachment_bytes( + storage_path: Optional[Path], *, size_limit: Optional[int] = None +) -> Optional[bytes]: if storage_path is None or not storage_path.is_file(): return None @@ -272,12 +274,7 @@ def build_attachment_prompt_text(result: Mapping[str, Any]) -> str: size_bytes = result.get("size_bytes") if size_bytes is not None: - return ( - "[上传文件: " - f"{display_name}, " - f"mime={mime_type}, " - f"bytes={size_bytes}]" - ) + return "[上传文件: " f"{display_name}, " f"mime={mime_type}, " f"bytes={size_bytes}]" return f"[上传文件引用: {display_name}, mime={mime_type}]" @@ -353,9 +350,11 @@ def _build_attachment_result_from_raw( if raw is None: result["status"] = "partial" if size_bytes else "failed" result["warnings"] = [ - "附件原始内容不可用,当前仅保留元数据摘要。" - if size_bytes - else "附件内容无法读取,请重新上传或检查文件句柄是否仍可访问。" + ( + "附件原始内容不可用,当前仅保留元数据摘要。" + if size_bytes + else "附件内容无法读取,请重新上传或检查文件句柄是否仍可访问。" + ) ] return result @@ -420,7 +419,9 @@ def _extract_document_attachment(result: dict[str, Any], raw: bytes) -> dict[str if text: return _with_text(result, text=text, extraction_method=extractor[1]) result["status"] = "failed" - result["warnings"] = [f"{suffix.lstrip('.').upper()} 文档抽取失败,请检查文件是否损坏或重新导出后重试。"] + result["warnings"] = [ + f"{suffix.lstrip('.').upper()} 文档抽取失败,请检查文件是否损坏或重新导出后重试。" + ] result["extraction_method"] = extractor[1] return result @@ -435,7 +436,9 @@ def _extract_document_attachment(result: dict[str, Any], raw: bytes) -> dict[str def _extract_image_attachment(result: dict[str, Any], raw: bytes) -> dict[str, Any]: - ocr = perform_ocr(raw, str(result.get("mime_type") or ""), str(result.get("display_name") or "")) + ocr = perform_ocr( + raw, str(result.get("mime_type") or ""), str(result.get("display_name") or "") + ) result["image"] = {"ocr_engine": ocr.get("engine") or ""} if ocr.get("text"): return _with_text(result, text=ocr["text"], extraction_method="image_ocr") @@ -524,9 +527,7 @@ def _extract_archive_attachment(result: dict[str, Any], raw: bytes) -> dict[str, } ) if child_result.get("text"): - extracted_chunks.append( - f"[{normalized_path}]\n{child_result['text']}" - ) + extracted_chunks.append(f"[{normalized_path}]\n{child_result['text']}") result["archive"] = { "entries": entries, @@ -578,9 +579,11 @@ def _load_attachment_bytes(attachment: Mapping[str, Any]) -> Optional[bytes]: return None return raw if len(raw) <= _MAX_PROCESS_BYTES else None - raw = read_attachment_uri_bytes(attachment.get("file_uri"), size_limit=_MAX_PROCESS_BYTES) - if raw is not None: - return raw + stored_raw = read_attachment_uri_bytes( + attachment.get("file_uri"), size_limit=_MAX_PROCESS_BYTES + ) + if stored_raw is not None: + return stored_raw return None @@ -621,7 +624,11 @@ def _extract_xlsx_text(raw: bytes) -> str: shared_strings = [] rows: list[str] = [] - for name in sorted(item for item in archive.namelist() if item.startswith("xl/worksheets/") and item.endswith(".xml")): + for name in sorted( + item + for item in archive.namelist() + if item.startswith("xl/worksheets/") and item.endswith(".xml") + ): try: root = ET.fromstring(archive.read(name)) except Exception: @@ -648,7 +655,9 @@ def _extract_xlsx_text(raw: bytes) -> str: if isinstance(child.tag, str) and child.tag.endswith("}t") ) else: - value_text = next((child.text or "" for child in cell if child.tag.endswith("}v")), "") + value_text = next( + (child.text or "" for child in cell if child.tag.endswith("}v")), "" + ) if value_text.strip(): values.append(value_text.strip()) if values: @@ -679,17 +688,19 @@ def _extract_xml_text_from_zip(raw: bytes, *, prefixes: tuple[str, ...]) -> str: texts: list[str] = [] with archive: for name in sorted( - item for item in archive.namelist() if item.endswith(".xml") and item.startswith(prefixes) + item + for item in archive.namelist() + if item.endswith(".xml") and item.startswith(prefixes) ): try: root = ET.fromstring(archive.read(name)) except Exception: continue - fragment = [ - node.text.strip() - for node in root.iter() - if isinstance(node.tag, str) and node.tag.endswith("}t") and (node.text or "").strip() - ] + fragment = [] + for node in root.iter(): + text = node.text or "" + if isinstance(node.tag, str) and node.tag.endswith("}t") and text.strip(): + fragment.append(text.strip()) if fragment: texts.append("\n".join(fragment)) return "\n\n".join(texts) @@ -710,22 +721,18 @@ def _pdf_text_quality_is_poor(text: str) -> bool: normalized = "".join(ch for ch in text if not ch.isspace()) if len(normalized) < 20: return True - readable = sum( - 1 - for ch in normalized - if ch.isalnum() or ("\u4e00" <= ch <= "\u9fff") - ) + readable = sum(1 for ch in normalized if ch.isalnum() or ("\u4e00" <= ch <= "\u9fff")) return readable / max(len(normalized), 1) < 0.5 def _perform_rapidocr(raw: bytes) -> str: try: - from rapidocr_onnxruntime import RapidOCR + rapidocr_module = importlib.import_module("rapidocr_onnxruntime") except Exception: return "" try: - engine = RapidOCR() + engine = rapidocr_module.RapidOCR() result, _ = engine(raw) except Exception: return "" @@ -741,7 +748,8 @@ def _perform_rapidocr(raw: bytes) -> str: def _perform_tesseract_ocr(raw: bytes) -> str: try: from PIL import Image - import pytesseract + + pytesseract = importlib.import_module("pytesseract") except Exception: return "" @@ -755,7 +763,7 @@ def _perform_tesseract_ocr(raw: bytes) -> str: text = pytesseract.image_to_string(image, lang=lang) except Exception: continue - if text and text.strip(): + if isinstance(text, str) and text.strip(): return text.strip() return "" diff --git a/ksadk/conversations/compaction_pipeline.py b/ksadk/conversations/compaction_pipeline.py index f982800f..654671e9 100644 --- a/ksadk/conversations/compaction_pipeline.py +++ b/ksadk/conversations/compaction_pipeline.py @@ -87,9 +87,7 @@ def _bool_env(name: str, default: bool) -> bool: def candidate_tokens(groups: Sequence[Sequence[SessionEvent]]) -> int: """估算 candidate groups 的总 token(用于 pipeline 渐进停止判断)。""" return sum( - estimate_text_tokens(extract_event_text(event)) - for group in groups - for event in group + estimate_text_tokens(extract_event_text(event)) for group in groups for event in group ) @@ -108,12 +106,7 @@ def _tool_signature(event: SessionEvent) -> str | None: return None content = event.content or {} metadata = event.metadata or {} - name = str( - metadata.get("tool_name") - or content.get("name") - or content.get("tool_name") - or "" - ) + name = str(metadata.get("tool_name") or content.get("name") or content.get("tool_name") or "") # 参数优先取 metadata.tool_args(真实 runtime),回退 content.arguments(合成/历史)。 arguments = ( metadata.get("tool_args") @@ -264,8 +257,14 @@ def microcompact_cold_groups( # 尾部 N 组保留(它们可能还被模型需要),更早的组视为冷组。 cold_rounds = _microcompact_cold_rounds() - cold_groups = list(groups[:-cold_rounds]) if cold_rounds < len(groups) else [] - tail_groups = list(groups[-cold_rounds:]) if cold_rounds < len(groups) else list(groups) + cold_groups = ( + [list(group) for group in groups[:-cold_rounds]] if cold_rounds < len(groups) else [] + ) + tail_groups = ( + [list(group) for group in groups[-cold_rounds:]] + if cold_rounds < len(groups) + else [list(group) for group in groups] + ) if not cold_groups: return MicrocompactResult( @@ -323,7 +322,8 @@ def build_working_set_metadata( ) -> dict[str, Any]: """L5 working set 恢复(保守版):只记 metadata,不读文件内容。 - recent_files: 调用方从 workspace_state 取最近 N 个文件的 {path, mtime_ns, size_bytes, read_range}。 + recent_files: 调用方从 workspace_state 取最近 N 个文件的 + {path, mtime_ns, size_bytes, read_range}。 active_tools: 调用方从 describe_agentengine_tools 取 enabled 工具名。 下一轮 prompt 注入时只提示"这些文件最近读过",不注入内容(避免 token 膨胀)。 """ diff --git a/ksadk/conversations/compaction_prompt.py b/ksadk/conversations/compaction_prompt.py index 1eedaf7e..28091814 100644 --- a/ksadk/conversations/compaction_prompt.py +++ b/ksadk/conversations/compaction_prompt.py @@ -92,10 +92,12 @@ def build_compaction_prompt_messages( 不把 prompt 字符串散落到 runtime 编排里。 """ - groups_text = "\n\n".join( - _format_group(group, index=index + 1) - for index, group in enumerate(groups_to_compact) - ).strip() or "无可压缩内容" + groups_text = ( + "\n\n".join( + _format_group(group, index=index + 1) for index, group in enumerate(groups_to_compact) + ).strip() + or "无可压缩内容" + ) user_prompt = _SUMMARY_USER_PROMPT.format( model_metadata=json.dumps(dict(model_metadata or {}), ensure_ascii=False, indent=2), previous_summary=previous_summary.strip() or "无", @@ -115,7 +117,9 @@ def extract_summary_text(response_text: str) -> str: if not raw: return "" - summary_match = re.search(r"\s*(.*?)\s*", raw, flags=re.DOTALL | re.IGNORECASE) + summary_match = re.search( + r"\s*(.*?)\s*", raw, flags=re.DOTALL | re.IGNORECASE + ) if summary_match: return summary_match.group(1).strip() diff --git a/ksadk/conversations/context.py b/ksadk/conversations/context.py index 1c9ea1be..d47c9112 100644 --- a/ksadk/conversations/context.py +++ b/ksadk/conversations/context.py @@ -2,6 +2,7 @@ import json import re +from collections.abc import Mapping, Sequence from typing import Any, Dict, Iterable, List from ksadk.sessions.base import SessionEvent @@ -33,9 +34,7 @@ "context_checkpoint", } -DATA_URL_RE = re.compile( - r"data:(?P[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+);base64,[A-Za-z0-9+/=_-]+" -) +DATA_URL_RE = re.compile(r"data:(?P[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+);base64,[A-Za-z0-9+/=_-]+") BASE64_FIELD_RE = re.compile( r"(?P['\"](?Pfile_data|data|bytes|base64)['\"]\s*:\s*['\"])(?P[A-Za-z0-9+/=_-]{512,})(?P['\"])", re.IGNORECASE, @@ -79,7 +78,9 @@ def extract_event_text(event: SessionEvent) -> str: if text: return sanitize_event_text_for_context(text) - return sanitize_event_text_for_context(extract_text_from_event_parts(content.get("parts") or [])) + return sanitize_event_text_for_context( + extract_text_from_event_parts(content.get("parts") or []) + ) def canonical_event_type( @@ -109,7 +110,11 @@ def canonical_event_type( return "run_checkpoint" if raw in {"run_resume", "runtime_resume"}: return "run_resume" - if raw in {"assistant", "model"} or role in {"assistant", "model"} or author in {"assistant", "model"}: + if ( + raw in {"assistant", "model"} + or role in {"assistant", "model"} + or author in {"assistant", "model"} + ): return "assistant_message" return "user_message" @@ -157,7 +162,9 @@ def build_request_history(messages: Iterable[Dict[str, Any]]) -> List[Dict[str, def compacted_until_seq_id(events: List[SessionEvent]) -> int: """读取最新 checkpoint 覆盖到哪一个 seq_id。""" - checkpoints = [event for event in events if canonical_event_type(event.event_type) == "context_checkpoint"] + checkpoints = [ + event for event in events if canonical_event_type(event.event_type) == "context_checkpoint" + ] if not checkpoints: return 0 latest = checkpoints[-1] @@ -301,3 +308,211 @@ def build_history_from_events(events: List[SessionEvent]) -> List[Dict[str, str] if role in {"user", "model"} and content: history.append({"role": role, "content": content}) return history + + +def _responses_json_string(value: Any, *, default: str = "") -> str: + if value is None: + return default + if isinstance(value, str): + return value or default + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError): + return str(value) + + +def _responses_message(role: str, text: Any) -> dict[str, Any] | None: + normalized_text = sanitize_event_text_for_context(text).strip() + if not normalized_text: + return None + return { + "role": role, + "content": [{"type": "input_text", "text": normalized_text}], + } + + +def _event_content_function_part(event: SessionEvent, part_name: str) -> Mapping[str, Any] | None: + content = event.content or {} + parts = content.get("parts") or [] + for part in parts: + if not isinstance(part, Mapping): + continue + value = part.get(part_name) + if isinstance(value, Mapping): + return value + return None + + +def _response_tool_call_id(event: SessionEvent) -> str: + metadata = event.metadata or {} + for key in ("tool_call_id", "call_id", "run_id"): + value = metadata.get(key) + if value: + return str(value) + receipt = metadata.get("tool_receipt") + if isinstance(receipt, Mapping): + for key in ("tool_call_id", "call_id", "run_id"): + value = receipt.get(key) + if value: + return str(value) + resume_input = metadata.get("resume_input") + if isinstance(resume_input, Mapping): + for key in ("tool_call_id", "call_id", "run_id"): + value = resume_input.get(key) + if value: + return str(value) + return "" + + +def _response_tool_call_item(event: SessionEvent) -> dict[str, Any] | None: + metadata = event.metadata or {} + content = event.content or {} + function_call = _event_content_function_part(event, "function_call") or {} + name = str( + metadata.get("tool_name") + or content.get("name") + or content.get("tool_name") + or function_call.get("name") + or "" + ).strip() + arguments: Any = metadata.get("tool_args") + if arguments is None: + arguments = ( + content.get("arguments") + or content.get("args") + or content.get("input") + or function_call.get("args") + or function_call.get("arguments") + or {} + ) + call_id = _response_tool_call_id(event) + if not name or not call_id: + return None + return { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": _responses_json_string(arguments, default="{}"), + } + + +def _response_tool_output_item(event: SessionEvent) -> dict[str, Any] | None: + metadata = event.metadata or {} + content = event.content or {} + function_response = _event_content_function_part(event, "function_response") or {} + output: Any = metadata.get("tool_output") if "tool_output" in metadata else None + if output is None: + resume_input = metadata.get("resume_input") + if isinstance(resume_input, Mapping) and "output" in resume_input: + output = resume_input.get("output") + if output is None: + output = ( + content.get("output") + or content.get("result") + or function_response.get("response") + or extract_event_text(event) + ) + call_id = _response_tool_call_id(event) + if not call_id: + return None + return { + "type": "function_call_output", + "call_id": call_id, + "output": _responses_json_string(output), + } + + +def project_responses_history(events: List[SessionEvent]) -> List[dict[str, Any]]: + """Project the compacted transcript into OpenAI Responses input items. + + The checkpoint summary represents the compacted prefix. Only the retained + tail is replayed as typed tool items, so an old call_id is never fabricated + from a text summary. Events without a reliable call id fall back to the + existing explanatory message representation instead of emitting an invalid + ``function_call_output`` item. + """ + projected: List[dict[str, Any]] = [] + projected_call_ids: set[str] = set() + compacted_until = compacted_until_seq_id(events) + checkpoint = next( + ( + event + for event in reversed(events) + if canonical_event_type(event.event_type) == "context_checkpoint" + ), + None, + ) + if checkpoint: + summary_message = _responses_message("assistant", extract_event_text(checkpoint)) + if summary_message: + projected.append(summary_message) + + for event in events: + event_type = canonical_event_type( + event.event_type, + author=event.author, + role=str((event.content or {}).get("role") or ""), + ) + if event.seq_id <= compacted_until and event_type != "context_checkpoint": + continue + if event_type not in TRANSCRIPT_EVENT_TYPES: + continue + if event_type in {"context_checkpoint", "compaction_boundary"}: + continue + + if event_type == "tool_call": + item = _response_tool_call_item(event) + if item: + projected.append(item) + projected_call_ids.add(str(item["call_id"])) + continue + message = _responses_message("assistant", f"[tool_call] {extract_event_text(event)}") + elif event_type == "tool_result": + item = _response_tool_output_item(event) + if item and str(item["call_id"]) in projected_call_ids: + projected.append(item) + continue + message = _responses_message("user", f"[tool_result] {extract_event_text(event)}") + elif event_type == "assistant_message": + message = _responses_message("assistant", extract_event_text(event)) + elif event_type == "approval_request": + message = _responses_message( + "assistant", f"[approval_request] {extract_event_text(event)}" + ) + elif event_type == "approval_response": + message = _responses_message("user", f"[approval_response] {extract_event_text(event)}") + elif event_type == "attachment_ref": + message = _responses_message("user", f"[attachment] {extract_event_text(event)}") + else: + message = _responses_message("user", extract_event_text(event)) + if message: + projected.append(message) + + return projected + + +def build_responses_history_from_messages( + messages: Sequence[Mapping[str, Any]], +) -> List[dict[str, Any]]: + """Normalize request-provided prior messages for Responses history.""" + history: List[dict[str, Any]] = [] + for message in messages or []: + role = str(message.get("role") or "").strip().lower() + if role == "model": + role = "assistant" + if role not in {"user", "assistant"}: + continue + + content = message.get("input_content") + if not isinstance(content, Sequence) or isinstance(content, (str, bytes, bytearray)): + content = message.get("content") + if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)): + content_items = [dict(item) for item in content if isinstance(item, Mapping)] + if content_items: + history.append({"role": role, "content": content_items}) + continue + + item = _responses_message(role, message.get("content")) + if item: + history.append(item) + return history diff --git a/ksadk/conversations/message_projection.py b/ksadk/conversations/message_projection.py index 5c5e816b..1b260f58 100644 --- a/ksadk/conversations/message_projection.py +++ b/ksadk/conversations/message_projection.py @@ -4,6 +4,14 @@ from typing import Any from urllib.parse import quote +from ksadk.agui.a2ui_projection import project_a2ui_operations +from ksadk.events.runtime_event import EventType + + +def _event_metadata(event: Mapping[str, Any]) -> Mapping[str, Any]: + metadata = event.get("Metadata") + return metadata if isinstance(metadata, Mapping) else {} + def project_session_messages( events: Sequence[Mapping[str, Any]], @@ -14,8 +22,13 @@ def project_session_messages( ) -> list[dict[str, Any]]: """Project persisted runtime events into the chat history contract.""" + agui_invocations = _agui_invocation_ids(events) + normalized = [ + _normalize_runtime_event(event, agui_invocations=agui_invocations) for event in events + ] + approval_responses = _approval_response_events(normalized) groups: dict[str, list[Mapping[str, Any]]] = {} - for event in sorted(events, key=lambda item: int(item.get("SeqId") or 0)): + for event in sorted(normalized, key=lambda item: int(item.get("SeqId") or 0)): invocation_id = str(event.get("InvocationId") or f"seq:{event.get('SeqId')}") groups.setdefault(invocation_id, []).append(event) @@ -27,6 +40,7 @@ def project_session_messages( include_reasoning=include_reasoning, include_tool_events=include_tool_events, include_attachments=include_attachments, + approval_responses=approval_responses, ) ) return sorted(messages, key=lambda item: int(item.get("SeqId") or 0)) @@ -38,15 +52,24 @@ def _project_event_group( include_reasoning: bool, include_tool_events: bool, include_attachments: bool, + approval_responses: Mapping[str, Mapping[str, Any]], ) -> list[dict[str, Any]]: reasoning = [ {"text": _event_text(event), "SeqId": event.get("SeqId")} for event in events if event.get("EventType") == "reasoning" and _event_text(event) ] - tool_events = _project_tool_events(events) if include_tool_events else [] + tool_events = ( + _project_tool_events(events, approval_responses=approval_responses) + if include_tool_events + else [] + ) + activities = _project_a2ui_activities(events) projected: list[dict[str, Any]] = [] assistant_seen = False + latest_snapshot: Mapping[str, Any] | None = None + streamed_text = "" + start_seq_id = min((int(event.get("SeqId") or 0) for event in events), default=0) for event in events: event_type = str(event.get("EventType") or "") @@ -63,36 +86,176 @@ def _project_event_group( message["Reasoning"] = reasoning if tool_events: message["ToolEvents"] = tool_events + if include_reasoning: + blocks = _project_interleaved_blocks(events, tool_events=tool_events) + if blocks: + message["Blocks"] = blocks projected.append(message) assistant_seen = True + elif event_type == "assistant_stream_snapshot": + latest_snapshot = event + elif event_type == "assistant_stream_delta": + streamed_text += _event_text(event) - if not assistant_seen and (reasoning or tool_events): + if not assistant_seen and ( + latest_snapshot is not None or streamed_text or reasoning or tool_events or activities + ): anchor = next( ( event for event in reversed(events) if event.get("EventType") - in {"assistant_stream_snapshot", "approval_request", "tool_call", "reasoning"} + in { + "assistant_stream_snapshot", + "assistant_stream_delta", + "approval_request", + "tool_call", + "reasoning", + EventType.A2UI_SURFACE_BEGIN, + EventType.A2UI_SURFACE_UPDATE, + EventType.A2UI_SURFACE_END, + } ), events[-1], ) - message = _base_message(anchor, "assistant", content="") + message = _base_message( + anchor, + "assistant", + content=( + _event_text(latest_snapshot) if latest_snapshot is not None else streamed_text + ), + ) if include_reasoning and reasoning: message["Reasoning"] = reasoning if tool_events: message["ToolEvents"] = tool_events + if include_reasoning: + blocks = _project_interleaved_blocks(events, tool_events=tool_events) + if blocks: + message["Blocks"] = blocks projected.append(message) + if activities: + assistant = next( + (message for message in reversed(projected) if message.get("Role") == "assistant"), + None, + ) + if assistant is not None: + assistant["Activities"] = activities + + for message in projected: + message["StartSeqId"] = start_seq_id return projected +def _project_interleaved_blocks( + events: Sequence[Mapping[str, Any]], + *, + tool_events: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + """Reconstruct ordered chat blocks from persisted streaming events. + + ``assistant_stream_snapshot`` carries a cumulative text value. The delta + relative to the prior snapshot is the text emitted at that point in the + event sequence. Reasoning events written before that snapshot can + therefore be replayed as ``thinking → text → thinking → text`` instead of + being flattened into the legacy ``Reasoning`` and ``Content`` fields. + + Older transcripts wrote all reasoning only at terminal time. Their first + reasoning event appears after streamed text, so their original boundaries + are unknowable. Return no blocks for those transcripts and let clients + use the existing best-effort compatibility projection. + """ + + blocks: list[dict[str, Any]] = [] + latest_snapshot_text = "" + saw_stream_text = False + saw_reasoning_after_stream_text = False + tools_by_seq_id = { + int(tool.get("SeqId") or 0): tool + for tool in tool_events + if int(tool.get("SeqId") or 0) > 0 + } + + def append_text(text: str, seq_id: Any) -> None: + if not text: + return + if blocks and blocks[-1].get("Type") == "text": + blocks[-1]["Content"] = f"{blocks[-1].get('Content') or ''}{text}" + return + blocks.append({"Type": "text", "Content": text, "SeqId": seq_id}) + + def append_thinking(text: str, seq_id: Any) -> None: + if not text: + return + if blocks and blocks[-1].get("Type") == "thinking": + blocks[-1]["Content"] = f"{blocks[-1].get('Content') or ''}{text}" + return + blocks.append({"Type": "thinking", "Content": text, "SeqId": seq_id}) + + def append_tool(event: Mapping[str, Any]) -> None: + tool = tools_by_seq_id.get(int(event.get("SeqId") or 0)) + if not tool: + return + blocks.append( + { + "Type": "tool", + "SeqId": tool.get("SeqId"), + "Name": tool.get("Name") or "tool", + "Args": tool.get("Args"), + "Result": tool.get("Result"), + "Status": tool.get("Status") or "completed", + "ToolCallId": tool.get("ToolCallId"), + } + ) + + for event in events: + event_type = str(event.get("EventType") or "") + text = _event_text(event) + seq_id = event.get("SeqId") + + if event_type == "reasoning": + metadata = _event_metadata(event) + if saw_stream_text and metadata.get("stream_boundary") != "before_text": + saw_reasoning_after_stream_text = True + append_thinking(text, seq_id) + continue + if event_type == "tool_call": + append_tool(event) + continue + if event_type == "assistant_stream_delta": + append_text(text, seq_id) + saw_stream_text = saw_stream_text or bool(text) + continue + if event_type == "assistant_stream_snapshot": + if text.startswith(latest_snapshot_text): + append_text(text[len(latest_snapshot_text) :], seq_id) + elif text != latest_snapshot_text: + # A replacement snapshot has no safe incremental boundary. + # Keep the latest content as a standalone text block instead + # of duplicating already-replayed output. + append_text(text, seq_id) + latest_snapshot_text = text + saw_stream_text = saw_stream_text or bool(text) + continue + if event_type == "assistant_message": + if latest_snapshot_text and text.startswith(latest_snapshot_text): + append_text(text[len(latest_snapshot_text) :], seq_id) + elif not latest_snapshot_text: + append_text(text, seq_id) + + if saw_reasoning_after_stream_text: + return [] + return blocks + + def _base_message( event: Mapping[str, Any], role: str, *, content: str | None = None, ) -> dict[str, Any]: - metadata = event.get("Metadata") if isinstance(event.get("Metadata"), Mapping) else {} + metadata = _event_metadata(event) message: dict[str, Any] = { "MessageId": event.get("EventId"), "Role": role, @@ -127,35 +290,56 @@ def _event_text(event: Mapping[str, Any]) -> str: ) -def _project_tool_events(events: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: +def _project_tool_events( + events: Sequence[Mapping[str, Any]], + *, + approval_responses: Mapping[str, Mapping[str, Any]], +) -> list[dict[str, Any]]: projected: list[dict[str, Any]] = [] - pending_calls: list[tuple[Mapping[str, Any], dict[str, Any]]] = [] + pending_calls: list[dict[str, Any]] = [] + pending_by_key: dict[str, dict[str, Any]] = {} + seen_result_receipts: set[str] = set() for event in events: event_type = str(event.get("EventType") or "") - metadata = event.get("Metadata") if isinstance(event.get("Metadata"), Mapping) else {} + metadata = _event_metadata(event) if event_type == "tool_call": entry = _tool_event_from_call(event, metadata) projected.append(entry) - pending_calls.append((event, entry)) + pending_calls.append(entry) + pair_key = _tool_pair_key(metadata) + if pair_key: + pending_by_key[pair_key] = entry continue if event_type != "tool_result": continue - name = str(metadata.get("tool_name") or "tool") - match = next( - ( - item - for item in reversed(pending_calls) - if item[1]["Name"] == name and item[1]["Status"] == "running" - ), - None, + receipt = metadata.get("tool_receipt") + receipt_key = ( + str(receipt.get("idempotency_key") or "") if isinstance(receipt, Mapping) else "" ) + if receipt_key and receipt_key in seen_result_receipts: + continue + if receipt_key: + seen_result_receipts.add(receipt_key) + + name = str(metadata.get("tool_name") or "tool") + pair_key = _tool_pair_key(metadata) + match = pending_by_key.get(pair_key) if pair_key else None + if match is None or match["Status"] != "running": + match = next( + ( + item + for item in reversed(pending_calls) + if item["Name"] == name and item["Status"] == "running" + ), + None, + ) if match is None: entry = _tool_event_from_call(event, metadata) projected.append(entry) else: - entry = match[1] + entry = match output = metadata.get("tool_output", _event_text(event)) entry["Status"] = ( "failed" if isinstance(output, Mapping) and output.get("ok") is False else "completed" @@ -163,13 +347,24 @@ def _project_tool_events(events: Sequence[Mapping[str, Any]]) -> list[dict[str, entry["Result"] = output entry["ResultSeqId"] = event.get("SeqId") - projected.extend(_project_approval_events(events)) + projected.extend(_project_approval_events(events, responses=approval_responses)) return projected -def _tool_event_from_call( - event: Mapping[str, Any], metadata: Mapping[str, Any] -) -> dict[str, Any]: +def _tool_pair_key(metadata: Mapping[str, Any]) -> str: + call_id = metadata.get("call_id") + if call_id: + return f"call:{call_id}" + tool_receipt = metadata.get("tool_receipt") + if isinstance(tool_receipt, Mapping) and tool_receipt.get("tool_call_id"): + return f"call:{tool_receipt['tool_call_id']}" + run_id = metadata.get("run_id") + if run_id: + return f"run:{run_id}:{metadata.get('tool_name') or 'tool'}" + return "" + + +def _tool_event_from_call(event: Mapping[str, Any], metadata: Mapping[str, Any]) -> dict[str, Any]: tool_receipt = metadata.get("tool_receipt") call_id = metadata.get("call_id") or metadata.get("run_id") if not call_id and isinstance(tool_receipt, Mapping): @@ -184,28 +379,16 @@ def _tool_event_from_call( } -def _project_approval_events(events: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: - responses: dict[str, Mapping[str, Any]] = {} - for event in events: - if event.get("EventType") != "approval_response": - continue - metadata = event.get("Metadata") if isinstance(event.get("Metadata"), Mapping) else {} - resume_input = metadata.get("resume_input") - if not isinstance(resume_input, Mapping): - continue - request_id = str( - resume_input.get("approval_request_id") - or resume_input.get("interrupt_id") - or resume_input.get("id") - or "" - ) - responses[request_id] = event - +def _project_approval_events( + events: Sequence[Mapping[str, Any]], + *, + responses: Mapping[str, Mapping[str, Any]], +) -> list[dict[str, Any]]: approvals: list[dict[str, Any]] = [] for request in events: if request.get("EventType") != "approval_request": continue - metadata = request.get("Metadata") if isinstance(request.get("Metadata"), Mapping) else {} + metadata = _event_metadata(request) interrupt_info = metadata.get("interrupt_info") if not isinstance(interrupt_info, Mapping): interrupt_info = {} @@ -213,18 +396,18 @@ def _project_approval_events(events: Sequence[Mapping[str, Any]]) -> list[dict[s interrupt_info.get("approval_request_id") or interrupt_info.get("id") or "" ) response = responses.get(request_id) - response_metadata = ( - response.get("Metadata") - if response is not None and isinstance(response.get("Metadata"), Mapping) - else {} - ) + response_metadata = _event_metadata(response) if response is not None else {} resume_input = response_metadata.get("resume_input") if not isinstance(resume_input, Mapping): resume_input = {} - status = "paused" if response is None else ( - "approved" - if resume_input.get("approve") or resume_input.get("approved") - else "denied" + status = ( + "paused" + if response is None + else ( + "approved" + if resume_input.get("approve") or resume_input.get("approved") + else "denied" + ) ) entry: dict[str, Any] = { "SeqId": request.get("SeqId"), @@ -233,6 +416,8 @@ def _project_approval_events(events: Sequence[Mapping[str, Any]]) -> list[dict[s "Status": status, "ApprovalRequestId": request_id or None, } + if metadata.get("protocol") == "ag-ui": + entry["Protocol"] = "ag-ui" arguments = ( interrupt_info.get("arguments") or interrupt_info.get("tool_args") @@ -240,12 +425,214 @@ def _project_approval_events(events: Sequence[Mapping[str, Any]]) -> list[dict[s ) if arguments is not None: entry["Args"] = arguments + approval_level = interrupt_info.get("approval_level") or interrupt_info.get("risk_level") + if approval_level: + entry["ApprovalLevel"] = str(approval_level) + approval_message = interrupt_info.get("approval_message") or interrupt_info.get("message") + if approval_message: + entry["ApprovalMessage"] = str(approval_message) approvals.append(entry) return approvals +def _approval_response_events( + events: Sequence[Mapping[str, Any]], +) -> dict[str, Mapping[str, Any]]: + responses: dict[str, Mapping[str, Any]] = {} + for event in events: + if event.get("EventType") != "approval_response": + continue + metadata = _event_metadata(event) + resume_input = metadata.get("resume_input") + if not isinstance(resume_input, Mapping): + continue + request_id = str( + resume_input.get("approval_request_id") + or resume_input.get("interrupt_id") + or resume_input.get("id") + or "" + ) + if request_id: + responses[request_id] = event + return responses + + +def _project_a2ui_activities(events: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + activities: list[dict[str, Any]] = [] + for event in events: + event_type = str(event.get("EventType") or "") + if event_type not in { + EventType.A2UI_SURFACE_BEGIN, + EventType.A2UI_SURFACE_UPDATE, + EventType.A2UI_SURFACE_END, + }: + continue + content = event.get("Content") + payload = content if isinstance(content, Mapping) else {} + operations = project_a2ui_operations(event_type, payload) + if not operations: + continue + surface_id = str(payload.get("surface_id") or payload.get("surfaceId") or "") + activities.append( + { + "SeqId": event.get("SeqId"), + "Type": "a2ui-surface", + "MessageId": f"{event.get('InvocationId')}:a2ui:{surface_id}", + "SurfaceId": surface_id, + "Content": { + "surfaceId": surface_id, + "a2ui_operations": operations, + }, + } + ) + return activities + + +def _agui_invocation_ids(events: Sequence[Mapping[str, Any]]) -> set[str]: + """Locate AG-UI runs so history written before ``payload.protocol`` remains usable.""" + invocation_ids: set[str] = set() + for event in events: + if str(event.get("EventType") or "") != EventType.RUN_STARTED: + continue + if not _event_metadata(event).get("ksadk_runtime_event"): + continue + content = event.get("Content") + payload = content.get("payload") if isinstance(content, Mapping) else None + if isinstance(payload, Mapping) and payload.get("source") == "ag-ui": + invocation_id = str(event.get("InvocationId") or "") + if invocation_id: + invocation_ids.add(invocation_id) + return invocation_ids + + +def _normalize_runtime_event( + event: Mapping[str, Any], + *, + agui_invocations: set[str], +) -> Mapping[str, Any]: + metadata = _event_metadata(event) + if not metadata.get("ksadk_runtime_event"): + return event + raw_content = event.get("Content") + content = raw_content if isinstance(raw_content, Mapping) else {} + payload = content.get("payload") + payload = payload if isinstance(payload, Mapping) else {} + event_type = str(event.get("EventType") or "") + normalized = dict(event) + normalized["Content"] = dict(payload) + normalized_metadata = dict(metadata) + + if event_type == EventType.RUN_STARTED and payload.get("source") == "ag-ui": + normalized["EventType"] = "user_message" + normalized["Content"] = {"text": _input_text(payload.get("input"))} + normalized["Author"] = "user" + elif event_type == EventType.TEXT_COMPLETED: + normalized["EventType"] = "assistant_message" + elif event_type == EventType.TEXT_DELTA: + normalized["EventType"] = "assistant_stream_delta" + elif event_type in {EventType.REASONING_DELTA, EventType.REASONING_COMPLETED}: + normalized["EventType"] = "reasoning" + elif event_type == EventType.TOOL_CALL_BEGIN: + normalized["EventType"] = "tool_call" + normalized_metadata.update( + { + "call_id": payload.get("call_id"), + "tool_name": payload.get("name"), + "tool_args": payload.get("args"), + } + ) + elif event_type == EventType.TOOL_CALL_END: + normalized["EventType"] = "tool_result" + normalized_metadata.update( + { + "call_id": payload.get("call_id"), + "tool_name": payload.get("name"), + "tool_output": payload.get("result", payload.get("error")), + } + ) + elif event_type == EventType.APPROVAL_REQUESTED: + detail = payload.get("detail") + detail = detail if isinstance(detail, Mapping) else {} + approval_request = detail.get("approval_requests") + approval_request = approval_request if isinstance(approval_request, Mapping) else detail + action_requests = approval_request.get("action_requests") + action = ( + next( + (item for item in action_requests if isinstance(item, Mapping)), + {}, + ) + if isinstance(action_requests, list) + else {} + ) + normalized["EventType"] = "approval_request" + normalized_metadata["interrupt_info"] = { + "approval_request_id": payload.get("approval_id") or payload.get("call_id"), + "id": payload.get("approval_id") or payload.get("call_id"), + "tool_name": action.get("name") + or approval_request.get("tool_name") + or detail.get("tool_name") + or payload.get("kind") + or "approval", + "arguments": action.get("args") + or action.get("arguments") + or approval_request.get("arguments") + or approval_request.get("args") + or detail.get("arguments") + or detail.get("args"), + "approval_level": action.get("approval_level") + or approval_request.get("approval_level") + or detail.get("approval_level") + or payload.get("approval_level"), + "approval_message": action.get("description") + or approval_request.get("message") + or detail.get("message") + or payload.get("message"), + } + is_agui_approval = payload.get("protocol") == "ag-ui" or ( + str(event.get("InvocationId") or "") in agui_invocations + ) + if is_agui_approval: + normalized_metadata["protocol"] = "ag-ui" + elif event_type == EventType.APPROVAL_RESOLVED: + decision = payload.get("decision") + normalized["EventType"] = "approval_response" + normalized_metadata["resume_input"] = { + "approval_request_id": payload.get("approval_id") or payload.get("call_id"), + "approve": _approval_decision_is_approved(decision), + "decision": decision, + } + normalized["Metadata"] = normalized_metadata + return normalized + + +def _approval_decision_is_approved(decision: Any) -> bool: + """Accept the historical AG-UI decision envelopes during transcript replay.""" + + if decision is True: + return True + if isinstance(decision, Mapping): + for key in ("approve", "approved"): + if key in decision: + return bool(decision[key]) + return _approval_decision_is_approved(decision.get("decision")) + return isinstance(decision, str) and decision.strip().lower() in {"approve", "approved"} + + +def _input_text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, Mapping): + if value.get("text") is not None: + return str(value.get("text") or "") + if value.get("content") is not None: + return _input_text(value.get("content")) + if isinstance(value, list): + return "".join(_input_text(item) for item in value) + return "" if value is None else str(value) + + def _event_attachments(event: Mapping[str, Any]) -> list[dict[str, Any]]: - metadata = event.get("Metadata") if isinstance(event.get("Metadata"), Mapping) else {} + metadata = _event_metadata(event) attachments: list[dict[str, Any]] = [] seen: set[str] = set() for item in metadata.get("attachments") or []: diff --git a/ksadk/conversations/model_context.py b/ksadk/conversations/model_context.py index f9404a5c..79deee59 100644 --- a/ksadk/conversations/model_context.py +++ b/ksadk/conversations/model_context.py @@ -79,12 +79,12 @@ def _coerce_token_limit(value: Any, *, assume_k_for_plain_values: bool = False) text = text[:-1] try: - parsed = float(text) + parsed_number = float(text) except (TypeError, ValueError): return None - if parsed <= 0: + if parsed_number <= 0: return None - resolved = int(parsed * multiplier) + resolved = int(parsed_number * multiplier) if multiplier == 1 and assume_k_for_plain_values and text.isdigit() and 0 < resolved <= 1000: return resolved * 1000 return resolved @@ -189,7 +189,9 @@ def get_context_window_tokens(model_metadata: Mapping[str, Any] | None = None) - if model_metadata.get("context_window_tokens"): limits["context_window_tokens"] = model_metadata["context_window_tokens"] - return _coerce_positive_int(limits.get("context_window_tokens")) or DEFAULT_CONTEXT_WINDOW_TOKENS + return ( + _coerce_positive_int(limits.get("context_window_tokens")) or DEFAULT_CONTEXT_WINDOW_TOKENS + ) def get_effective_context_window_tokens(model_metadata: Mapping[str, Any] | None = None) -> int: @@ -202,7 +204,9 @@ def get_effective_context_window_tokens(model_metadata: Mapping[str, Any] | None limits["max_output_tokens"] = model_metadata["max_output_tokens"] context_window = get_context_window_tokens(model_metadata) - max_output_tokens = _coerce_positive_int(limits.get("max_output_tokens")) or DEFAULT_MAX_OUTPUT_TOKENS + max_output_tokens = ( + _coerce_positive_int(limits.get("max_output_tokens")) or DEFAULT_MAX_OUTPUT_TOKENS + ) reserved_tokens = min(max_output_tokens, AUTOCOMPACT_SUMMARY_RESERVE_TOKENS) return max(1, context_window - reserved_tokens) @@ -366,5 +370,7 @@ def normalize_model_metadata(raw_model: Mapping[str, Any] | str | None) -> dict[ "pricing": pricing, } normalized["auto_compact_threshold_tokens"] = get_auto_compact_threshold_tokens(normalized) - normalized["auto_compact_threshold_percentage"] = get_auto_compact_threshold_percentage(normalized) + normalized["auto_compact_threshold_percentage"] = get_auto_compact_threshold_percentage( + normalized + ) return normalized diff --git a/ksadk/conversations/model_options.py b/ksadk/conversations/model_options.py index df2ccee8..8d274fd5 100644 --- a/ksadk/conversations/model_options.py +++ b/ksadk/conversations/model_options.py @@ -2,7 +2,6 @@ from typing import Any, Mapping - _VALID_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} _CHAT_COMPLETIONS_REASONING_EFFORTS = {"low", "medium", "high"} _PASSTHROUGH_OPTION_KEYS = {"temperature", "top_p", "max_tokens", "max_completion_tokens"} diff --git a/ksadk/conversations/normalize.py b/ksadk/conversations/normalize.py index aab7786f..2a58e2c3 100644 --- a/ksadk/conversations/normalize.py +++ b/ksadk/conversations/normalize.py @@ -1,21 +1,15 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional from pathlib import Path +from typing import Any, Dict, List, Optional from ksadk.conversations.attachments import ( build_attachment_prompt_text, build_attachment_results, - compact_attachment_result_for_session, decode_inline_data, - extract_pdf_text, - is_textual_mime, looks_like_textual_attachment, - read_attachment_bytes, resolve_attachment_storage_path, - resolve_uploads_dir, ) -from ksadk.conversations.context import canonical_event_type from ksadk.server.api_models import Part @@ -110,7 +104,9 @@ def _inline_data_from_openai_file(item: Dict[str, Any]) -> Optional[Dict[str, An display_name = _openai_file_display_name(item) return { "data": data.strip(), - "mimeType": str(item.get("mime_type") or item.get("mimeType") or _guess_mime_type(display_name)), + "mimeType": str( + item.get("mime_type") or item.get("mimeType") or _guess_mime_type(display_name) + ), "displayName": display_name, } @@ -122,7 +118,9 @@ def _file_data_from_openai_file(item: Dict[str, Any]) -> Optional[Dict[str, Any] display_name = _openai_file_display_name(item) return { "fileUri": file_uri.strip(), - "mimeType": str(item.get("mime_type") or item.get("mimeType") or _guess_mime_type(display_name)), + "mimeType": str( + item.get("mime_type") or item.get("mimeType") or _guess_mime_type(display_name) + ), "displayName": display_name, } @@ -173,9 +171,7 @@ def _canonical_input_content_from_part_payload(payload: Dict[str, Any]) -> Optio def _canonical_input_content_from_item(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: item_type = str(item.get("type") or "").strip() - if item_type in {"input_text", "text"} or ( - not item_type and item.get("text") is not None - ): + if item_type in {"input_text", "text"} or (not item_type and item.get("text") is not None): return {"type": "input_text", "text": str(item.get("text") or "")} if item_type in {"input_image", "image_url"}: @@ -318,7 +314,9 @@ def attachment_from_part(part: Part) -> Optional[Dict[str, Any]]: mime_type = (file_data.mimeType or "").strip() or "application/octet-stream" storage_path = resolve_attachment_storage_path(file_data.fileUri or "") try: - size_bytes = storage_path.stat().st_size if storage_path and storage_path.exists() else None + size_bytes = ( + storage_path.stat().st_size if storage_path and storage_path.exists() else None + ) except OSError: size_bytes = None return { @@ -360,7 +358,9 @@ def display_content_from_parts(parts: List[Part]) -> str: def normalize_parts_content(parts: List[Part]) -> dict[str, Any]: - attachments = [attachment for attachment in (attachment_from_part(part) for part in parts) if attachment] + attachments = [ + attachment for attachment in (attachment_from_part(part) for part in parts) if attachment + ] attachment_results = build_attachment_results(attachments) display_content = display_content_from_parts(parts) segments: List[str] = [] @@ -373,7 +373,9 @@ def normalize_parts_content(parts: List[Part]) -> dict[str, Any]: segments.append(build_attachment_prompt_text(attachment_results[attachment_index])) attachment_index += 1 return { - "content": "\n\n".join(segment.strip() for segment in segments if str(segment).strip()).strip(), + "content": "\n\n".join( + segment.strip() for segment in segments if str(segment).strip() + ).strip(), "display_content": display_content, "parts": [part.model_dump(exclude_none=True) for part in parts], "attachments": attachments, @@ -436,7 +438,9 @@ def normalize_responses_input(input_payload: Any) -> List[Dict[str, Any]]: "content": input_payload, "display_content": input_payload, "parts": [{"text": input_payload}] if input_payload else [], - "input_content": [{"type": "input_text", "text": input_payload}] if input_payload else [], + "input_content": ( + [{"type": "input_text", "text": input_payload}] if input_payload else [] + ), "attachments": [], "attachment_results": [], } diff --git a/ksadk/conversations/reasoning_markup.py b/ksadk/conversations/reasoning_markup.py index 39c2b148..70af5e86 100644 --- a/ksadk/conversations/reasoning_markup.py +++ b/ksadk/conversations/reasoning_markup.py @@ -30,7 +30,7 @@ def feed(self, chunk: str) -> list[ReasoningMarkupPart]: index = self._buffer.find(tag) if index >= 0: self._append(parts, self._buffer[:index]) - self._buffer = self._buffer[index + len(tag):] + self._buffer = self._buffer[index + len(tag) :] self._in_think = not self._in_think continue diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index 2ff7b9c2..99cabf92 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -1,5276 +1,360 @@ from __future__ import annotations import asyncio -import json -import logging -import os -import time -import uuid -from contextlib import asynccontextmanager, nullcontext -from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Callable, Dict, Mapping, Optional, Sequence - -import httpx -from fastapi import HTTPException - -from ksadk.conversations.attachments import compact_attachment_result_for_session -from ksadk.conversations.compaction_pipeline import build_working_set_metadata, run_pipeline -from ksadk.conversations.context import ( - TRANSCRIPT_EVENT_TYPES, - build_history_from_events, - build_request_history, - canonical_event_type, - compacted_until_seq_id, - extract_event_text, - group_events_by_api_round, +import sys +import types +from typing import Any + +from ksadk.conversations import runtime_compaction as _runtime_compaction +from ksadk.conversations import runtime_governance as _runtime_governance +from ksadk.conversations import runtime_input as _runtime_input +from ksadk.conversations import runtime_invocation as _runtime_invocation +from ksadk.conversations import runtime_metadata as _runtime_metadata +from ksadk.conversations import runtime_observability as _runtime_observability +from ksadk.conversations import runtime_payloads as _runtime_payloads +from ksadk.conversations import runtime_persistence as _runtime_persistence +from ksadk.conversations import runtime_preparation as _runtime_preparation +from ksadk.conversations import runtime_resume as _runtime_resume +from ksadk.conversations import runtime_stream_events as _runtime_stream_events +from ksadk.conversations import runtime_streaming as _runtime_streaming +from ksadk.conversations.runtime_compaction import ( + _plan_compaction, + compact_conversation_history, + preview_auto_compaction, +) +from ksadk.conversations.runtime_constants import ( + _MODEL_CATALOG_CACHE, + _MODEL_CATALOG_CACHE_TTL_SECONDS, + ATTACHMENT_CONTEXT_STATE_KEY, + AUTOCOMPACT_KEEP_TAIL_GROUPS, + EVENT_SCAN_PAGE_SIZE, + PROMPT_TOO_LONG_MARKERS, + PTL_RETRY_KEEP_TAIL_GROUPS, + SESSION_SUMMARY_MAX_CHARS, +) +from ksadk.conversations.runtime_governance import ( + RuntimeCircuitOpen, + RuntimeGovernanceState, + _compact_conversation_history_with_governance, + _governance_error, + _governance_record_approval_response, + _governance_record_compact_failure, + _governance_record_compact_success, + _governance_record_tool_call, + _governance_record_tool_result, + _governance_record_turn_start, + _int_env, + _runtime_governance_from_env, + _tool_observability_metadata, +) +from ksadk.conversations.runtime_input import ( + _ambient_context_has_error, + _ambient_policy, + _attachment_summary_for_memory, + _auto_save_ltm_turn, + _build_runner_ambient_contexts, + _build_runner_request_payload, + _contains_any_fragment, + _env_flag, + _inject_runner_deferred_tools_for_request, + _input_text_for_memory, + _is_chitchat_query, + _is_prompt_too_long_error, + _ltm_auto_save_enabled, + _memory_turn_event_strings, + _merge_request_history_with_session_history, + _merge_responses_history_with_session_history, + _normalize_ambient_query, + _runner_name, + _runner_type_name, + _should_load_kb_ambient_context, + _should_load_memory_ambient_context, + _should_use_platform_ambient_context, +) +from ksadk.conversations.runtime_invocation import ( + _response_sse, + invoke_conversation_once, +) +from ksadk.conversations.runtime_metadata import ( + _attachment_context_from_session, + _build_attachment_context_state_delta, + _build_pending_user_event, + _canonical_input_messages, + _fetch_remote_model_catalog, + _latest_attachment_context_from_messages, + _latest_user_turn, + _model_catalog_endpoint, + _normalized_conversation_messages, + _parts_include_file, + _refine_session_title_in_background, + _resolve_effective_attachment_context, + _resolve_model_metadata, + _resolve_runtime_model_metadata, + _transcript_event_type, + _truncate_text, + _update_session_metadata_after_assistant_turn, + _update_session_metadata_after_user_turn, + _user_event_content, + prime_session_metadata_for_user_turn, ) -from ksadk.conversations.model_context import ( - estimate_text_tokens, - get_auto_compact_threshold_percentage, - get_auto_compact_threshold_tokens, - normalize_model_metadata, +from ksadk.conversations.runtime_observability import ( + _chat_usage_payload, + _conversation_span_scope, + _current_span_feedback_metadata, + _extract_deferred_tool_names, + _get_conversation_tracer, + _get_current_span, + _latest_deferred_tool_names, + _normalize_usage_payload, + _responses_usage_payload, + _set_conversation_input_attributes, + _set_conversation_output_attributes, + _set_conversation_span_attributes, + _set_conversation_usage_attributes, + _set_span_attribute, + _span_current_context, + _span_feedback_metadata, + _usage_from_metadata, ) -from ksadk.conversations.model_options import normalize_model_options -from ksadk.conversations.normalize import ( - canonical_input_content_from_parts, - compact_attachment_for_session, - normalize_kop_messages, +from ksadk.conversations.runtime_payloads import ( + CompactionPlan, + PreparedConversationTurn, + build_chat_completions_payload, + build_compaction_sse_event, + build_responses_payload, + extract_responses_resume_input, ) -from ksadk.conversations.reasoning_markup import strip_reasoning_markup -from ksadk.conversations.run_kinds import ( - RUN_MODE_FOREGROUND, - RUN_MODE_UNKNOWN, - RUN_TRIGGER_APPROVAL_RESUME, - RUN_TRIGGER_CHECKPOINT_RESUME, - RUN_TRIGGER_NEW_RUN, - RUN_TRIGGER_UNKNOWN, - trigger_from_resume_input, - validate_run_mode, - validate_run_trigger, +from ksadk.conversations.runtime_persistence import ( + _find_latest_session_event, + append_context_checkpoint_event, + append_conversation_event, + append_deferred_tools_event, + append_reasoning_event, + append_run_checkpoint_event, + append_run_resume_event, + append_run_status_event, + ensure_conversation_session, ) -from ksadk.conversations.semantic_summary import ( - extract_pinned_state, - find_pinned_group_indexes, - summarize_compaction, +from ksadk.conversations.runtime_preparation import ( + _refresh_history, + build_run_input, +) +from ksadk.conversations.runtime_resume import ( + _agentengine_resume_metadata, + _approval_decision_from_resume, + _approval_request_events, + _approval_request_id_from_event, + _approval_resume_run_mode, + _builtin_tool_callable, + _checkpoint_event_args_from_agentengine_metadata, + _consecutive_approval_denials_from_events, + _execute_approved_builtin_tool_resume, + _extract_agentengine_metadata, + _failed_status_for_resume, + _filter_responses_reasoning_output, + _find_tool_receipt_event_by_key, + _format_resume_response_text, + _has_pending_approval, + _is_approval_resume_input, + _is_checkpoint_resume_input, + _latest_checkpoint_metadata_for_run, + _merge_agentengine_metadata, + _model_options_disable_reasoning, + _normalize_approval_resume_input, + _normalize_checkpoint_resume_input, + _parse_approval_arguments, + _pending_approval_events, + _responses_output_item_text, + _semantic_events_from_responses_output, + _stringify_responses_item_value, + _tool_receipt_idempotency_key_for_resume, + _tool_receipt_metadata, + _tool_receipt_status_from_output, + _tool_resume_run_id, +) +from ksadk.conversations.runtime_stream_events import ( + _iter_conversation_turn_events, +) +from ksadk.conversations.runtime_streaming import ( + stream_conversation_turn, + stream_responses_conversation_turn, ) from ksadk.conversations.session_title import ( - DEFAULT_SESSION_TITLE_TIMEOUT_MS, - HEURISTIC_SESSION_TITLE_SOURCE, - build_fallback_title, - build_heuristic_title, - build_session_title_messages, - is_low_quality_title, resolve_session_title_client, - resolve_session_title_model, ) from ksadk.knowledge_base.service import KnowledgeBaseService from ksadk.memory.service import LongTermMemoryService -from ksadk.model_policy import fallback_model_for_exception, model_policy_options_for_model -from ksadk.runtime_context import ( - PlatformInvocationContext, - platform_invocation_scope, - tool_execution_scope, -) -from ksadk.sessions import Session, SessionEvent, resolve_session_service -from ksadk.tools.gateway import ( - approval_interrupt_info_from_result, - build_tool_receipt_idempotency_key, -) - -AUTOCOMPACT_KEEP_TAIL_GROUPS = 4 -PTL_RETRY_KEEP_TAIL_GROUPS = 2 -PROMPT_TOO_LONG_MARKERS = ( - "prompt-too-long", - "prompt too long", - "maximum context length", - "context length", - "context_length_exceeded", - "413", +from ksadk.sessions import resolve_session_service + +_FACADE_EXPORTS = ( + ATTACHMENT_CONTEXT_STATE_KEY, + AUTOCOMPACT_KEEP_TAIL_GROUPS, + CompactionPlan, + EVENT_SCAN_PAGE_SIZE, + KnowledgeBaseService, + LongTermMemoryService, + PROMPT_TOO_LONG_MARKERS, + PTL_RETRY_KEEP_TAIL_GROUPS, + PreparedConversationTurn, + RuntimeCircuitOpen, + RuntimeGovernanceState, + SESSION_SUMMARY_MAX_CHARS, + _MODEL_CATALOG_CACHE, + _MODEL_CATALOG_CACHE_TTL_SECONDS, + _agentengine_resume_metadata, + _ambient_context_has_error, + _ambient_policy, + _approval_decision_from_resume, + _approval_request_events, + _approval_request_id_from_event, + _approval_resume_run_mode, + _attachment_context_from_session, + _attachment_summary_for_memory, + _auto_save_ltm_turn, + _build_attachment_context_state_delta, + _build_pending_user_event, + _build_runner_ambient_contexts, + _build_runner_request_payload, + _builtin_tool_callable, + _canonical_input_messages, + _chat_usage_payload, + _checkpoint_event_args_from_agentengine_metadata, + _compact_conversation_history_with_governance, + _consecutive_approval_denials_from_events, + _contains_any_fragment, + _conversation_span_scope, + _current_span_feedback_metadata, + _env_flag, + _execute_approved_builtin_tool_resume, + _extract_agentengine_metadata, + _extract_deferred_tool_names, + _failed_status_for_resume, + _fetch_remote_model_catalog, + _filter_responses_reasoning_output, + _find_latest_session_event, + _find_tool_receipt_event_by_key, + _format_resume_response_text, + _get_conversation_tracer, + _get_current_span, + _governance_error, + _governance_record_approval_response, + _governance_record_compact_failure, + _governance_record_compact_success, + _governance_record_tool_call, + _governance_record_tool_result, + _governance_record_turn_start, + _has_pending_approval, + _inject_runner_deferred_tools_for_request, + _input_text_for_memory, + _int_env, + _is_approval_resume_input, + _is_checkpoint_resume_input, + _is_chitchat_query, + _is_prompt_too_long_error, + _iter_conversation_turn_events, + _latest_attachment_context_from_messages, + _latest_checkpoint_metadata_for_run, + _latest_deferred_tool_names, + _latest_user_turn, + _ltm_auto_save_enabled, + _memory_turn_event_strings, + _merge_agentengine_metadata, + _merge_request_history_with_session_history, + _merge_responses_history_with_session_history, + _model_catalog_endpoint, + _model_options_disable_reasoning, + _normalize_ambient_query, + _normalize_approval_resume_input, + _normalize_checkpoint_resume_input, + _normalize_usage_payload, + _normalized_conversation_messages, + _parse_approval_arguments, + _parts_include_file, + _pending_approval_events, + _plan_compaction, + _refine_session_title_in_background, + _refresh_history, + _resolve_effective_attachment_context, + _resolve_model_metadata, + _resolve_runtime_model_metadata, + _response_sse, + _responses_output_item_text, + _responses_usage_payload, + _runner_name, + _runner_type_name, + _runtime_governance_from_env, + _semantic_events_from_responses_output, + _set_conversation_input_attributes, + _set_conversation_output_attributes, + _set_conversation_span_attributes, + _set_conversation_usage_attributes, + _set_span_attribute, + _should_load_kb_ambient_context, + _should_load_memory_ambient_context, + _should_use_platform_ambient_context, + _span_current_context, + _span_feedback_metadata, + _stringify_responses_item_value, + _tool_observability_metadata, + _tool_receipt_idempotency_key_for_resume, + _tool_receipt_metadata, + _tool_receipt_status_from_output, + _tool_resume_run_id, + _transcript_event_type, + _truncate_text, + _update_session_metadata_after_assistant_turn, + _update_session_metadata_after_user_turn, + _usage_from_metadata, + _user_event_content, + append_context_checkpoint_event, + append_conversation_event, + append_deferred_tools_event, + append_reasoning_event, + append_run_checkpoint_event, + append_run_resume_event, + append_run_status_event, + asyncio, + 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, + resolve_session_service, + resolve_session_title_client, + stream_conversation_turn, + stream_responses_conversation_turn, ) -SESSION_SUMMARY_MAX_CHARS = 160 -ATTACHMENT_CONTEXT_STATE_KEY = "__ksadk_attachment_context__" - -logger = logging.getLogger(__name__) -_MODEL_CATALOG_CACHE_TTL_SECONDS = 60.0 -_MODEL_CATALOG_CACHE: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {} - - -@dataclass -class RuntimeGovernanceState: - max_turns: int = 0 - max_tool_calls: int = 0 - max_consecutive_tool_failures: int = 0 - max_consecutive_approval_denials: int = 0 - max_consecutive_compact_failures: int = 0 - turn_count: int = 0 - tool_calls: int = 0 - consecutive_tool_failures: int = 0 - consecutive_approval_denials: int = 0 - consecutive_compact_failures: int = 0 - - -class RuntimeCircuitOpen(RuntimeError): - def __init__(self, reason: str, message: str, *, metadata: Mapping[str, Any] | None = None): - super().__init__(message) - self.reason = reason - self.metadata = dict(metadata or {}) - - -def _int_env(name: str, default: int = 0) -> int: - raw = os.environ.get(name) - if raw is None: - return int(default) - try: - return max(0, int(raw)) - except ValueError: - return int(default) - - -def _runtime_governance_from_env() -> RuntimeGovernanceState: - return RuntimeGovernanceState( - max_turns=_int_env("KSADK_MAX_TURNS", 0), - max_tool_calls=_int_env("KSADK_MAX_TOOL_CALLS", 0), - max_consecutive_tool_failures=_int_env("KSADK_MAX_CONSECUTIVE_TOOL_FAILURES", 0), - max_consecutive_approval_denials=_int_env("KSADK_MAX_CONSECUTIVE_APPROVAL_DENIALS", 0), - max_consecutive_compact_failures=_int_env("KSADK_MAX_CONSECUTIVE_COMPACT_FAILURES", 0), - ) - - -def _governance_error( - reason: str, message: str, state: RuntimeGovernanceState -) -> RuntimeCircuitOpen: - return RuntimeCircuitOpen( - reason, - message, - metadata={ - "reason": reason, - "max_turns": state.max_turns, - "max_tool_calls": state.max_tool_calls, - "max_consecutive_tool_failures": state.max_consecutive_tool_failures, - "max_consecutive_approval_denials": state.max_consecutive_approval_denials, - "max_consecutive_compact_failures": state.max_consecutive_compact_failures, - "turn_count": state.turn_count, - "tool_calls": state.tool_calls, - "consecutive_tool_failures": state.consecutive_tool_failures, - "consecutive_approval_denials": state.consecutive_approval_denials, - "consecutive_compact_failures": state.consecutive_compact_failures, - }, - ) - - -def _governance_record_turn_start(state: RuntimeGovernanceState) -> None: - state.turn_count += 1 - if state.max_turns and state.turn_count > state.max_turns: - raise _governance_error("max_turns_exceeded", "runtime max_turns limit exceeded", state) - - -def _governance_record_tool_call(state: RuntimeGovernanceState) -> None: - state.tool_calls += 1 - if state.max_tool_calls and state.tool_calls > state.max_tool_calls: - raise _governance_error( - "max_tool_calls_exceeded", "runtime max_tool_calls limit exceeded", state - ) - - -def _governance_record_tool_result(state: RuntimeGovernanceState, output: Any) -> None: - failed = isinstance(output, Mapping) and output.get("ok") is False - state.consecutive_tool_failures = state.consecutive_tool_failures + 1 if failed else 0 - if ( - state.max_consecutive_tool_failures - and state.consecutive_tool_failures >= state.max_consecutive_tool_failures - ): - raise _governance_error( - "consecutive_tool_failures", "runtime consecutive tool failure limit exceeded", state - ) - - -def _governance_record_approval_response( - state: RuntimeGovernanceState, approval: Mapping[str, Any] -) -> None: - approved = bool(approval.get("approved") or approval.get("approve")) - state.consecutive_approval_denials = 0 if approved else state.consecutive_approval_denials + 1 - if ( - state.max_consecutive_approval_denials - and state.consecutive_approval_denials >= state.max_consecutive_approval_denials - ): - raise _governance_error( - "consecutive_approval_denials", - "runtime consecutive approval denial limit exceeded", - state, - ) - - -def _governance_record_compact_failure(state: RuntimeGovernanceState) -> None: - state.consecutive_compact_failures += 1 - if ( - state.max_consecutive_compact_failures - and state.consecutive_compact_failures >= state.max_consecutive_compact_failures - ): - raise _governance_error( - "consecutive_compact_failures", - "runtime consecutive compact failure limit exceeded", - state, - ) - - -def _governance_record_compact_success(state: RuntimeGovernanceState) -> None: - state.consecutive_compact_failures = 0 - - -async def _compact_conversation_history_with_governance( - governance: RuntimeGovernanceState | None, - **kwargs: Any, -) -> SessionEvent | None: - try: - checkpoint = await compact_conversation_history(**kwargs) - except Exception: - if governance is not None: - _governance_record_compact_failure(governance) - raise - if checkpoint is not None and governance is not None: - _governance_record_compact_success(governance) - return checkpoint - - -def _tool_observability_metadata( - tool_name: str, output: Any, *, duration_ms: int | None = None -) -> dict[str, Any]: - output_text = ( - json.dumps(output, ensure_ascii=False, sort_keys=True) - if isinstance(output, Mapping) - else str(output) - ) - metadata: dict[str, Any] = { - "tool_name": tool_name, - "duration_ms": int(duration_ms or 0), - "output_chars": len(output_text), - "truncated": False, - "persisted": False, - "exit_code": None, - "error_type": "", - } - if isinstance(output, Mapping): - persisted = output.get("persisted") or output.get("persisted_outputs") - metadata.update( - { - "truncated": any( - bool(output.get(key)) - for key in ( - "truncated", - "stdout_truncated", - "stderr_truncated", - "results_truncated", - ) - ), - "persisted": bool(persisted), - "exit_code": output.get("exit_code"), - "error_type": str(output.get("error_type") or ""), - } - ) - return metadata - - -def _extract_deferred_tool_names(output: Any) -> list[str]: - if not isinstance(output, Mapping): - return [] - raw_names = output.get("deferred_tool_names") - if not isinstance(raw_names, Sequence) or isinstance(raw_names, (str, bytes, bytearray)): - return [] - names: list[str] = [] - seen: set[str] = set() - for item in raw_names: - name = str(item or "").strip() - if not name or name in seen: - continue - seen.add(name) - names.append(name) - return names - - -def _latest_deferred_tool_names(events: Sequence[SessionEvent]) -> list[str]: - for event in reversed(events): - if event.event_type != "run_status": - continue - metadata = event.metadata or {} - if metadata.get("detail") != "deferred_tools_selected": - continue - return _extract_deferred_tool_names(metadata) - return [] - - -def _normalize_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any]: - if not isinstance(usage, Mapping): - return {} - normalized: dict[str, Any] = {} - for key in ( - "input_tokens", - "output_tokens", - "total_tokens", - "prompt_tokens", - "completion_tokens", - ): - value = usage.get(key) - if value is None: - continue - try: - normalized[key] = int(value) - except (TypeError, ValueError): - continue - for key in ("input_token_details", "output_token_details"): - value = usage.get(key) - if isinstance(value, Mapping): - normalized[key] = dict(value) - prompt_details = usage.get("prompt_tokens_details") - if isinstance(prompt_details, Mapping): - normalized["prompt_tokens_details"] = dict(prompt_details) - cached_tokens = prompt_details.get("cached_tokens") - if cached_tokens is not None: - try: - normalized.setdefault("input_token_details", {})["cached"] = int(cached_tokens) - except (TypeError, ValueError): - pass - completion_details = usage.get("completion_tokens_details") - if isinstance(completion_details, Mapping): - normalized["completion_tokens_details"] = dict(completion_details) - reasoning_tokens = completion_details.get("reasoning_tokens") - if reasoning_tokens is not None: - try: - normalized.setdefault("output_token_details", {})["reasoning"] = int( - reasoning_tokens - ) - except (TypeError, ValueError): - pass - return normalized - - -def _usage_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]: - if not isinstance(metadata, Mapping): - return {} - return _normalize_usage_payload(metadata.get("usage")) - - -def _responses_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | None: - normalized = _normalize_usage_payload(usage) - if not normalized: - return None - input_tokens = normalized.get("input_tokens", normalized.get("prompt_tokens", 0)) - output_tokens = normalized.get("output_tokens", normalized.get("completion_tokens", 0)) - total_tokens = normalized.get("total_tokens", input_tokens + output_tokens) - payload = { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "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"]) - return payload - - -def _chat_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | None: - normalized = _normalize_usage_payload(usage) - if not normalized: - return None - prompt_tokens = normalized.get("prompt_tokens", normalized.get("input_tokens", 0)) - completion_tokens = normalized.get("completion_tokens", normalized.get("output_tokens", 0)) - total_tokens = normalized.get("total_tokens", prompt_tokens + completion_tokens) - payload = { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, - } - prompt_details = normalized.get("prompt_tokens_details") - if isinstance(prompt_details, Mapping): - payload["prompt_tokens_details"] = dict(prompt_details) - input_token_details = normalized.get("input_token_details") - if isinstance(input_token_details, Mapping) and input_token_details: - prompt_details = dict(payload.get("prompt_tokens_details") or {}) - cached_tokens = input_token_details.get("cached") - if cached_tokens is not None: - try: - prompt_details["cached_tokens"] = int(cached_tokens) - except (TypeError, ValueError): - pass - if prompt_details: - payload["prompt_tokens_details"] = prompt_details - completion_details = normalized.get("completion_tokens_details") - if isinstance(completion_details, Mapping): - payload["completion_tokens_details"] = dict(completion_details) - output_token_details = normalized.get("output_token_details") - if isinstance(output_token_details, Mapping) and output_token_details: - completion_details = dict(payload.get("completion_tokens_details") or {}) - reasoning_tokens = output_token_details.get("reasoning") - if reasoning_tokens is not None: - try: - completion_details["reasoning_tokens"] = int(reasoning_tokens) - except (TypeError, ValueError): - pass - if completion_details: - payload["completion_tokens_details"] = completion_details - return payload - - -def _get_conversation_tracer() -> Any | None: - try: - from opentelemetry import trace - - return trace.get_tracer("ksadk.conversations") - except Exception: - return None - - -def _current_span_feedback_metadata() -> dict[str, str]: - try: - from opentelemetry import trace - - span = trace.get_current_span() - except Exception: - return {} - return _span_feedback_metadata(span) - - -def _span_feedback_metadata(span: Any | None) -> dict[str, str]: - if span is None: - return {} - try: - context = span.get_span_context() - except Exception: - return {} - if not getattr(context, "is_valid", False): - return {} - return { - "trace_id": format(context.trace_id, "032x"), - "root_span_id": format(context.span_id, "016x"), - } - - -def _get_current_span() -> Any | None: - try: - from opentelemetry import trace - - return trace.get_current_span() - except Exception: - return None - - -def _span_current_context(span: Any | None): - if span is None: - return nullcontext() - try: - from opentelemetry.trace import use_span - - return use_span( - span, end_on_exit=False, record_exception=False, set_status_on_exception=False - ) - except Exception: - return nullcontext() - - -@asynccontextmanager -async def _conversation_span_scope(name: str, *, manual_end: bool = False): - tracer = _get_conversation_tracer() - if tracer is None: - yield None - return - if manual_end: - span = tracer.start_span(name) - try: - yield span - finally: - try: - span.end() - except Exception: - pass - return - span = tracer.start_span(name) - try: - yield span - finally: - try: - span.end() - except Exception: - pass - - -def _set_span_attribute(span: Any | None, key: str, value: Any) -> None: - if span is None: - return - if value is None: - return - if isinstance(value, str): - value = value.strip() - if not value: - return - try: - span.set_attribute(key, value) - except Exception: - return - - -def _set_conversation_input_attributes(span: Any | None, input_text: str | None) -> None: - text = " ".join(str(input_text or "").split()) - if not text: - return - for key in ( - "langfuse.trace.input", - "langfuse.observation.input", - "input.value", - "gen_ai.prompt", - ): - _set_span_attribute(span, key, text) - - -def _set_conversation_output_attributes(span: Any | None, output_text: str | None) -> None: - text = " ".join(str(output_text or "").split()) - if not text: - return - for key in ( - "langfuse.trace.output", - "langfuse.observation.output", - "output.value", - "gen_ai.completion", - ): - _set_span_attribute(span, key, text) - - -def _set_conversation_usage_attributes( - span: Any | None, - usage: Mapping[str, Any] | None, -) -> None: - normalized = _normalize_usage_payload(usage) - if not normalized: - return - - def _usage_int(value: Any) -> int: - try: - return int(value or 0) - except (TypeError, ValueError): - return 0 - - input_tokens = _usage_int(normalized.get("input_tokens")) - output_tokens = _usage_int(normalized.get("output_tokens")) - total_tokens = _usage_int(normalized.get("total_tokens") or (input_tokens + output_tokens)) - input_details = normalized.get("input_token_details") - output_details = normalized.get("output_token_details") - cache_read_tokens = 0 - reasoning_tokens = 0 - if isinstance(input_details, Mapping): - cache_read_tokens = _usage_int( - input_details.get("cache_read") - or input_details.get("cached") - or input_details.get("cached_tokens") - ) - if isinstance(output_details, Mapping): - reasoning_tokens = _usage_int( - output_details.get("reasoning") - or output_details.get("reasoning_tokens") - ) - - attributes = { - "gen_ai.usage.input_tokens": input_tokens, - "gen_ai.usage.output_tokens": output_tokens, - "gen_ai.usage.total_tokens": total_tokens, - "llm.usage.prompt_tokens": input_tokens, - "llm.usage.completion_tokens": output_tokens, - "llm.usage.total_tokens": total_tokens, - } - if cache_read_tokens: - attributes["gen_ai.usage.cache_read.input_tokens"] = cache_read_tokens - attributes["llm.usage.cache_read.input_tokens"] = cache_read_tokens - if reasoning_tokens: - attributes["gen_ai.usage.reasoning.output_tokens"] = reasoning_tokens - attributes["llm.usage.reasoning_tokens"] = reasoning_tokens - - for key, value in attributes.items(): - if value: - _set_span_attribute(span, key, value) - - -def _set_conversation_span_attributes( - span: Any, - *, - agent_id: str, - user_id: str, - session_id: str, - invocation_id: str, - runner_name: str, - model: str | None, - response_id: str | None = None, -) -> None: - if span is None: - return - try: - span.set_attribute("ksadk.agent_id", agent_id) - span.set_attribute("ksadk.user_id", user_id) - span.set_attribute("ksadk.session_id", session_id) - span.set_attribute("ksadk.invocation_id", invocation_id) - span.set_attribute("ksadk.runner", runner_name) - span.set_attribute("langfuse.trace.name", runner_name) - span.set_attribute("langfuse.session.id", session_id) - span.set_attribute("session.id", session_id) - span.set_attribute("langfuse.user.id", user_id) - span.set_attribute("user.id", user_id) - if model: - span.set_attribute("llm.model_name", model) - span.set_attribute("gen_ai.request.model", model) - if response_id: - span.set_attribute("ksadk.response_id", response_id) - except Exception: - return - - -@dataclass -class PreparedConversationTurn: - """一次 turn 编排后的标准输入。 - - 这个对象把“会话归属”“用户最新输入”“投影后的上下文 history” - 和“附件/parts”等运行时所需信息收拢到一起,避免不同 endpoint - 各自重新拼装。 - """ - - session_id: str - invocation_id: str - user_input: str - user_display_input: str - history: list[dict[str, str]] - input_content: list[dict[str, Any]] - input_messages: list[dict[str, Any]] - user_parts: list[dict[str, Any]] - attachments: list[dict[str, Any]] - attachment_results: list[dict[str, Any]] - current_attachments: list[dict[str, Any]] - current_attachment_results: list[dict[str, Any]] - has_current_files: bool - model_metadata: dict[str, Any] = field(default_factory=dict) - model_options: dict[str, Any] = field(default_factory=dict) - instructions: str = "" - request_metadata: dict[str, Any] = field(default_factory=dict) - compaction_triggered: bool = False - compaction_trigger: str | None = None - compacted_until_seq_id: int | None = None - resume_input: dict[str, Any] | None = None - # 双维度 run 标识:run_mode=怎么跑(background/foreground), - # run_trigger=怎么开始(new_run/checkpoint_resume/approval_resume) - run_mode: str = RUN_MODE_FOREGROUND - run_trigger: str = RUN_TRIGGER_NEW_RUN - - -@dataclass -class CompactionPlan: - """一次 compaction 规划结果。 - - 预览阶段和真正落 checkpoint 阶段都复用这份规划,避免 `/run_sse` - 与 conversation runtime 各自写一套“是否需要压缩”的条件判断。 - """ - - should_compact: bool - groups_to_compact: list[list[SessionEvent]] - total_chars: int - total_estimated_tokens: int - group_count: int - tail_groups: int - auto_compact_threshold_tokens: int | None = None - auto_compact_threshold_percentage: int | None = None - compacted_until_seq_id: int | None = None - pinned_group_indexes: list[int] = field(default_factory=list) - pinned_state: dict[str, Any] = field(default_factory=dict) - - -def build_responses_payload( - *, - output_text: str, - model: Optional[str], - session_id: str, - response_id: str | None = None, - created_at: int | None = None, - status: str = "completed", - metadata: Mapping[str, Any] | None = None, - incomplete_details: Mapping[str, Any] | None = None, - error: Mapping[str, Any] | None = None, - output_items: Sequence[Mapping[str, Any]] | None = None, -) -> dict[str, Any]: - response_id = response_id or f"resp_{uuid.uuid4().hex}" - created_at = created_at or int(time.time()) - message_id = f"msg_{uuid.uuid4().hex[:12]}" - output_item_status = "completed" if status == "completed" else status - message_item = { - "id": message_id, - "type": "message", - "status": output_item_status, - "role": "assistant", - "content": [{"type": "output_text", "text": output_text}], - } - normalized_output_items = [ - dict(item) for item in list(output_items or []) if isinstance(item, Mapping) - ] - if normalized_output_items: - output = normalized_output_items - if not any(str(item.get("type") or "") == "message" for item in output): - output = [message_item, *output] - else: - output = [message_item] - usage = _responses_usage_payload(_usage_from_metadata(metadata)) - return { - "id": response_id, - "object": "response", - "created_at": created_at, - "status": status, - "error": dict(error) if isinstance(error, Mapping) else None, - "incomplete_details": ( - dict(incomplete_details) if isinstance(incomplete_details, Mapping) else None - ), - "instructions": None, - "metadata": dict(metadata or {}), - "model": model or "agent", - "parallel_tool_calls": True, - "temperature": None, - "top_p": None, - "tools": [], - "output": output, - "output_text": output_text, - "usage": usage, - "session_id": session_id, - } - - -def extract_responses_resume_input(input_payload: Any) -> dict[str, Any] | None: - """Extract OpenAI Responses approval resume input without exposing runner details.""" - if isinstance(input_payload, Mapping): - candidates = [input_payload] - elif isinstance(input_payload, Sequence) and not isinstance( - input_payload, (str, bytes, bytearray) - ): - candidates = [item for item in input_payload if isinstance(item, Mapping)] - else: - return None - - for item in candidates: - item_type = str(item.get("type") or "").strip() - if item_type == "agentengine.resume_checkpoint": - resume_input = {"type": "agentengine.resume_checkpoint"} - for key in ( - "run_id", - "checkpoint_id", - "resume_attempt_id", - "framework", - "framework_ref", - ): - if key in item: - resume_input[key] = item.get(key) - return resume_input - - if item_type == "mcp_approval_response": - resume_input: dict[str, Any] = {"type": "mcp_approval_response"} - if item.get("id"): - resume_input["id"] = str(item.get("id")) - approval_request_id = item.get("approval_request_id") - if approval_request_id: - resume_input["approval_request_id"] = str(approval_request_id) - if "approve" in item: - resume_input["approve"] = item.get("approve") - elif "approved" in item: - resume_input["approve"] = item.get("approved") - if item.get("reason") is not None: - resume_input["reason"] = str(item.get("reason") or "") - return resume_input - - if item_type == "function_call_output": - call_id = item.get("call_id") - if not call_id: - continue - resume_input = { - "type": "function_call_output", - "call_id": str(call_id), - "output": item.get("output", ""), - } - if item.get("id"): - resume_input["id"] = str(item.get("id")) - return resume_input - - if item_type in {"ksadk_resume", "ksadk.approval_response"}: - resume_input = {"type": "ksadk_resume"} - interrupt_id = ( - item.get("interrupt_id") or item.get("approval_request_id") or item.get("id") - ) - if interrupt_id: - resume_input["interrupt_id"] = str(interrupt_id) - if "value" in item: - resume_input["value"] = item.get("value") - elif "resume" in item: - resume_input["value"] = item.get("resume") - else: - resume_input["value"] = { - key: value - for key, value in item.items() - if key not in {"type", "interrupt_id", "approval_request_id", "id"} - } - return resume_input - - return None - - -def build_chat_completions_payload( - *, - output_text: str, - model: Optional[str], - session_id: str, - metadata: Mapping[str, Any] | None = None, -) -> dict[str, Any]: - usage = _chat_usage_payload(_usage_from_metadata(metadata)) - payload = { - "id": f"chatcmpl-{uuid.uuid4()}", - "object": "chat.completion", - "created": int(time.time()), - "model": model or "agent", - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": output_text}, - "finish_reason": "stop", - } - ], - "usage": usage, - "session_id": session_id, - } - if isinstance(metadata, Mapping) and metadata: - payload["metadata"] = dict(metadata) - return payload - - -def build_compaction_sse_event( - *, - phase: str, - trigger: str, - compacted_until_seq_id: int | None = None, - total_chars: int | None = None, - total_estimated_tokens: int | None = None, - group_count: int | None = None, - threshold_percentage: int | None = None, -) -> str: - """统一生成 compaction 相关 SSE,方便不同入口保持同一语义。""" - - payload: dict[str, Any] = { - "phase": phase, - "trigger": trigger, - "timestamp": int(time.time() * 1000), - } - if compacted_until_seq_id is not None: - payload["compacted_until_seq_id"] = compacted_until_seq_id - if total_chars is not None: - payload["total_chars"] = total_chars - if total_estimated_tokens is not None: - payload["total_estimated_tokens"] = total_estimated_tokens - if group_count is not None: - payload["group_count"] = group_count - if threshold_percentage is not None: - payload["threshold_percentage"] = threshold_percentage - return ( - f"event: response.compaction.{phase}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" - ) - - -def _is_prompt_too_long_error(exc: Exception) -> bool: - """尽量用宽松规则识别 PTL,兼容不同 runtime/模型返回格式。""" - lowered = str(exc or "").lower() - return any(marker in lowered for marker in PROMPT_TOO_LONG_MARKERS) - - -def _runner_name(runner: Any) -> str: - return str(getattr(getattr(runner, "detection_result", None), "name", "assistant")) - - -def _runner_type_name(runner: Any) -> str: - runner_type = getattr(getattr(runner, "detection_result", None), "type", None) - runner_value = getattr(runner_type, "value", runner_type) - normalized = str(runner_value or "").strip() - if normalized: - return normalized - return runner.__class__.__name__.lower() - - -def _env_flag(name: str, default: bool = True) -> bool: - raw = os.getenv(name) - if raw is None: - return default - normalized = str(raw).strip().lower() - if not normalized: - return default - return normalized not in {"0", "false", "no", "off"} - - -def _ltm_auto_save_enabled() -> bool: - backend = str(os.getenv("KSADK_LTM_BACKEND") or "").strip().lower() - namespace = str(os.getenv("KSADK_LTM_NAMESPACE") or "").strip() - if not backend and not namespace: - return False - default = backend == "sdk" and bool(namespace) - return _env_flag("KSADK_LTM_AUTO_SAVE", default) - - -def _ambient_policy(prefix: str, default: str = "on_demand") -> str: - if not _env_flag(f"{prefix}_AMBIENT_ENABLED", True): - return "disabled" - - raw = str(os.getenv(f"{prefix}_AMBIENT_POLICY", default) or "").strip().lower() - if raw in {"", "on_demand", "ondemand", "heuristic", "auto"}: - return "on_demand" - if raw in {"always", "eager"}: - return "always" - if raw in {"disabled", "off", "false", "0"}: - return "disabled" - return default - - -def _normalize_ambient_query(text: str) -> str: - return " ".join(str(text or "").strip().lower().split()) - - -def _contains_any_fragment(text: str, fragments: Sequence[str]) -> bool: - return any(fragment in text for fragment in fragments) - - -def _ambient_context_has_error(context: Any) -> bool: - if not isinstance(context, dict): - return True - - formatted_text = str(context.get("formatted_text") or "").strip() - if not formatted_text: - return True - - failure_prefixes = ( - "知识库检索失败", - "长期记忆检索失败", - ) - return formatted_text.startswith(failure_prefixes) - - -def _is_chitchat_query(text: str) -> bool: - normalized = _normalize_ambient_query(text) - if not normalized: - return True - - exact_matches = { - "hi", - "hello", - "hey", - "你好", - "您好", - "嗨", - "在吗", - "收到", - "好的", - "ok", - "okay", - "thanks", - "thank you", - "谢谢", - "测试", - "test", - "ping", - } - if normalized in exact_matches: - return True - - chatter_fragments = ( - "介绍一下你自己", - "介绍你自己", - "你是谁", - "你能做什么", - "what can you do", - "who are you", - "introduce yourself", - "一句测试", - ) - return any(fragment in normalized for fragment in chatter_fragments) - - -def _should_load_kb_ambient_context(user_input: str) -> bool: - normalized = _normalize_ambient_query(user_input) - if not normalized or _is_chitchat_query(normalized): - return False - - kb_fragments = ( - "知识库", - "文档", - "手册", - "说明", - "wiki", - "资料", - "教程", - "api", - "接口", - "参数", - "配置", - "规格", - "机型", - "实例", - "部署", - "步骤", - "区别", - "差异", - "原理", - "架构", - "能力", - "限制", - "最佳实践", - "价格", - "套餐", - "支持", - "有哪些", - "什么是", - "为什么", - "怎么", - "如何", - "查询", - "查一下", - "列一下", - "介绍一下", - "总结", - "概述", - "解释", - "说明一下", - "对比", - "比较", - "what", - "how", - "why", - "which", - "list", - "show me", - "tell me", - "summarize", - "summary", - "explain", - "difference", - "steps", - "deployment", - "lookup", - "look up", - "search", - "compare", - ) - if _contains_any_fragment(normalized, kb_fragments): - return True - - query_verbs = ( - "查", - "查一下", - "查询", - "列出", - "列一下", - "总结", - "概述", - "解释", - "说明", - "介绍", - "对比", - "比较", - "看看", - "告诉我", - "what", - "how", - "why", - "which", - "list", - "show", - "tell", - "summarize", - "explain", - "compare", - ) - kb_subjects = ( - "知识库", - "文档", - "手册", - "教程", - "wiki", - "部署", - "步骤", - "api", - "接口", - "参数", - "配置", - "规格", - "机型", - "实例", - "价格", - "套餐", - "支持", - "区别", - "差异", - "原理", - "架构", - "能力", - "限制", - ) - return _contains_any_fragment(normalized, query_verbs) and _contains_any_fragment( - normalized, kb_subjects - ) - - -def _should_load_memory_ambient_context(user_input: str) -> bool: - normalized = _normalize_ambient_query(user_input) - if not normalized or _is_chitchat_query(normalized): - return False - - explicit_memory_fragments = ( - "记得", - "记住", - "记忆", - "回忆", - "历史", - "偏好", - "习惯", - "还记得", - "记得我", - "记住这个", - "remember", - "memory", - "recall", - "history", - "preference", - ) - profile_fragments = ( - "我的名字", - "我叫什么", - "你知道我的名字", - "我的风格", - "按我的风格", - "按照我的风格", - "我的偏好", - "我的习惯", - "我的背景", - "关于我的", - "我喜欢", - "我不喜欢", - "my name", - "my style", - "my preference", - "about me", - ) - short_term_fragments = ( - "前面的回答", - "前面的内容", - "上面的回答", - "上面的内容", - "刚才的回答", - "刚刚的回答", - "上一条", - "上一轮", - "继续刚才", - "继续上面", - "翻译成英文", - "翻译成中文", - ) - temporal_fragments = ("上次", "之前", "以前", "earlier", "last time", "previous") - speech_fragments = ("聊过", "说过", "提过", "告诉过", "mentioned", "told") - - if _contains_any_fragment(normalized, short_term_fragments) and not _contains_any_fragment( - normalized, profile_fragments - ): - return False - - if _contains_any_fragment(normalized, explicit_memory_fragments) or _contains_any_fragment( - normalized, profile_fragments - ): - return True - - return _contains_any_fragment(normalized, temporal_fragments) and _contains_any_fragment( - normalized, speech_fragments - ) - - -def _should_use_platform_ambient_context(runner: Any) -> bool: - detection_type = getattr(getattr(runner, "detection_result", None), "type", None) - runner_type = str(getattr(detection_type, "value", detection_type) or "").strip().lower() - if runner_type: - return runner_type != "adk" - - class_name = runner.__class__.__name__.lower() - module_name = getattr(runner.__class__, "__module__", "").lower() - return class_name != "adkrunner" and "google_adk" not in module_name - - -def _build_runner_ambient_contexts( - *, - runner: Any, - user_id: str, - user_input: str, -) -> dict[str, Any]: - contexts: dict[str, Any] = { - "kb_context": None, - "memory_context": None, - } - normalized_input = str(user_input or "").strip() - if not normalized_input or not _should_use_platform_ambient_context(runner): - return contexts - - kb_policy = _ambient_policy("KSADK_KB", "on_demand") - if ( - kb_policy == "always" - or (kb_policy == "on_demand" and _should_load_kb_ambient_context(normalized_input)) - ) and KnowledgeBaseService.is_configured(): - try: - kb_context = KnowledgeBaseService.from_env().build_context(normalized_input) - if not _ambient_context_has_error(kb_context): - contexts["kb_context"] = kb_context - except Exception as exc: - logger.warning("Failed to build ambient knowledge context: %s", exc) - - ltm_policy = _ambient_policy("KSADK_LTM", "on_demand") - if ( - ltm_policy == "always" - or (ltm_policy == "on_demand" and _should_load_memory_ambient_context(normalized_input)) - ) and LongTermMemoryService.is_configured(): - try: - memory_context = LongTermMemoryService.from_env().build_context( - user_id=user_id, - query=normalized_input, - ) - if not _ambient_context_has_error(memory_context): - contexts["memory_context"] = memory_context - except Exception as exc: - logger.warning("Failed to build ambient memory context: %s", exc) - - return contexts - - -def _build_runner_request_payload( - *, - prepared: PreparedConversationTurn, - model: str | None, - runtime_context: PlatformInvocationContext, -) -> dict[str, Any]: - payload = { - "session_id": prepared.session_id, - "input": prepared.user_input, - "history": prepared.history, - "input_content": prepared.input_content, - "input_messages": prepared.input_messages, - "input_parts": prepared.user_parts, - "attachments": prepared.attachments, - "attachment_results": prepared.attachment_results, - "current_attachments": prepared.current_attachments, - "current_attachment_results": prepared.current_attachment_results, - "has_current_files": prepared.has_current_files, - "model": model, - "model_metadata": prepared.model_metadata, - "model_options": prepared.model_options, - "platform_context": runtime_context.to_payload(), - "kb_context": runtime_context.kb_context, - "memory_context": runtime_context.memory_context, - "invocation_id": prepared.invocation_id, - } - if prepared.instructions: - payload["instructions"] = prepared.instructions - if prepared.resume_input is not None: - if _is_checkpoint_resume_input(prepared.resume_input): - payload["input"] = prepared.resume_input - payload["checkpoint_resume"] = True - payload["run_id"] = str(prepared.resume_input.get("run_id") or "") - payload["checkpoint_id"] = str(prepared.resume_input.get("checkpoint_id") or "") - payload["framework_ref"] = dict(prepared.resume_input.get("framework_ref") or {}) - payload["metadata"] = dict(prepared.resume_input.get("metadata") or {}) - payload["checkpoint_metadata"] = dict( - prepared.resume_input.get("checkpoint_metadata") or {} - ) - else: - payload["input"] = prepared.resume_input - payload["resume"] = True - previous_response_id = prepared.request_metadata.get("previous_response_id") - if previous_response_id: - payload["previous_response_id"] = str(previous_response_id) - conversation = prepared.request_metadata.get("conversation") - if conversation: - payload["conversation"] = conversation - if prepared.request_metadata.get("responses_conversation"): - payload["responses_conversation"] = True - deferred_tool_names = _extract_deferred_tool_names(prepared.request_metadata) - if deferred_tool_names: - payload["deferred_tool_names"] = deferred_tool_names - return payload - - -def _inject_runner_deferred_tools_for_request( - runner: Any, prepared: PreparedConversationTurn -) -> None: - deferred_tool_names = _extract_deferred_tool_names(prepared.request_metadata) - if not deferred_tool_names: - return - inject = getattr(runner, "inject_deferred_tools_for_request", None) - if not callable(inject): - return - try: - inject(deferred_tool_names) - except Exception as exc: - logger.warning("Failed to inject deferred tools into runner: %s", exc) - - -def _attachment_summary_for_memory( - attachments: Sequence[Mapping[str, Any]], - attachment_results: Sequence[Mapping[str, Any]], -) -> list[dict[str, Any]]: - summaries: list[dict[str, Any]] = [] - for item in attachment_results: - if not isinstance(item, Mapping): - continue - summary = { - "kind": str(item.get("kind") or "file"), - "display_name": str( - item.get("display_name") or item.get("filename") or "uploaded_file" - ), - "mime_type": str(item.get("mime_type") or "application/octet-stream"), - } - summaries.append(summary) - - if summaries: - return summaries - - for item in attachments: - if not isinstance(item, Mapping): - continue - mime_type = str(item.get("mime_type") or "application/octet-stream") - display_name = str(item.get("display_name") or item.get("filename") or "uploaded_file") - kind = "image" if mime_type.startswith("image/") else "file" - summaries.append( - { - "kind": kind, - "display_name": display_name, - "mime_type": mime_type, - } - ) - return summaries - - -def _memory_turn_event_strings( - *, - prepared: PreparedConversationTurn, - output_text: str, - metadata: Mapping[str, Any], -) -> list[str]: - event_strings: list[str] = [] - user_text = _input_text_for_memory(prepared) - if user_text: - user_metadata = dict(metadata) - attachment_summary = _attachment_summary_for_memory( - prepared.current_attachments, - prepared.current_attachment_results, - ) - if attachment_summary: - user_metadata["attachments"] = attachment_summary - event_strings.append( - json.dumps( - { - "role": "user", - "parts": [{"text": user_text}], - "metadata": user_metadata, - }, - ensure_ascii=False, - ) - ) - - assistant_text = strip_reasoning_markup(str(output_text or "")).strip() - if assistant_text: - event_strings.append( - json.dumps( - { - "role": "assistant", - "parts": [{"text": assistant_text}], - "metadata": dict(metadata), - }, - ensure_ascii=False, - ) - ) - return event_strings - - -def _input_text_for_memory(prepared: PreparedConversationTurn) -> str: - text_parts: list[str] = [] - for item in prepared.input_content: - if isinstance(item, Mapping) and item.get("type") == "input_text": - text = str(item.get("text") or "").strip() - if text: - text_parts.append(text) - if text_parts: - return "\n".join(text_parts).strip() - return str(prepared.user_input or prepared.user_display_input or "").strip() - - -async def _auto_save_ltm_turn( - *, - agent_id: str, - user_id: str, - prepared: PreparedConversationTurn, - output_text: str, - runner_type: str, - model: str | None, -) -> None: - if prepared.resume_input is not None or not _ltm_auto_save_enabled(): - return - - metadata: dict[str, Any] = { - "agent_id": str(agent_id or ""), - "session_id": prepared.session_id, - "invocation_id": prepared.invocation_id, - "runner_type": runner_type, - } - if model: - metadata["model"] = model - - platform_context = prepared.request_metadata.get("platform_context") - if isinstance(platform_context, Mapping): - metadata["agent_id"] = str(platform_context.get("agent_id") or metadata["agent_id"]) - - if not metadata["agent_id"]: - metadata["agent_id"] = str(os.getenv("KSADK_LTM_AGENT_ID") or "") - - event_strings = _memory_turn_event_strings( - prepared=prepared, - output_text=output_text, - metadata=metadata, - ) - if not event_strings: - return - - try: - service = LongTermMemoryService.from_env() - service.save_event_strings( - user_id=user_id, - event_strings=event_strings, - metadata=metadata, - ) - except Exception as exc: - logger.warning("Failed to auto-save conversation turn to long-term memory: %s", exc) - - -def _merge_request_history_with_session_history( - request_history: Sequence[dict[str, str]], - session_history: Sequence[dict[str, str]], -) -> list[dict[str, str]]: - if not request_history: - return list(session_history) - if not session_history: - return list(request_history) - - normalized_request = [ - { - "role": str(item.get("role") or ""), - "content": str(item.get("content") or "").strip(), - } - for item in request_history - ] - normalized_session = [ - { - "role": str(item.get("role") or ""), - "content": str(item.get("content") or "").strip(), - } - for item in session_history - ] - prefix_len = min(len(normalized_request), len(normalized_session)) - if normalized_request[:prefix_len] == normalized_session[:prefix_len]: - return [*list(request_history), *list(session_history)[prefix_len:]] - return [*list(request_history), *list(session_history)] - - -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 ""), - ) - if event_type == "approval_request": - pending += 1 - elif event_type == "approval_response" and pending > 0: - pending -= 1 - return pending > 0 - - -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) - 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 ""), - ) - if event_type == "approval_request": - pending.append(event) - continue - if event_type != "approval_response" or not pending: - continue - resume_input = (event.metadata or {}).get("resume_input") - response_id = "" - if isinstance(resume_input, Mapping): - response_id = str( - resume_input.get("approval_request_id") - or resume_input.get("interrupt_id") - or resume_input.get("id") - or "" - ) - if response_id: - pending = [ - item for item in pending if _approval_request_id_from_event(item) != response_id - ] - else: - pending.pop() - return pending - - -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" - ] - - -def _approval_resume_run_mode( - events: Sequence[SessionEvent], - resume_input: Mapping[str, Any], - *, - fallback: str, -) -> str: - target_ids = { - str(value) - for value in ( - resume_input.get("approval_request_id"), - resume_input.get("interrupt_id"), - resume_input.get("id"), - ) - if value - } - approval_events = _pending_approval_events(events) or _approval_request_events(events) - for approval_event in reversed(approval_events): - approval_id = _approval_request_id_from_event(approval_event) - if target_ids and approval_id not in target_ids: - continue - approval_invocation_id = str(approval_event.invocation_id or "") - if not approval_invocation_id: - continue - for event in reversed(events): - if event.event_type != "run_status" or event.invocation_id != approval_invocation_id: - continue - metadata = event.metadata or {} - state_delta = event.state_delta or {} - active_run = state_delta.get("active_run") if isinstance(state_delta, Mapping) else None - state_mode = active_run.get("run_mode") if isinstance(active_run, Mapping) else None - mode = validate_run_mode(str(metadata.get("run_mode") or state_mode or "")) - if mode != RUN_MODE_UNKNOWN: - return mode - break - return validate_run_mode(fallback) - - -def _parse_approval_arguments(value: Any) -> dict[str, Any]: - if isinstance(value, Mapping): - return dict(value) - if isinstance(value, str) and value.strip(): - try: - parsed = json.loads(value) - except json.JSONDecodeError: - return {} - return dict(parsed) if isinstance(parsed, Mapping) else {} - return {} - - -def _approval_decision_from_resume(resume_input: Mapping[str, Any]) -> dict[str, Any]: - if "approve" in resume_input: - approved = bool(resume_input.get("approve")) - elif "approved" in resume_input: - approved = bool(resume_input.get("approved")) - else: - approved = bool( - resume_input.get("value", {}).get("approved") - if isinstance(resume_input.get("value"), Mapping) - else True - ) - decision = { - "approved": approved, - "approval_request_id": str( - resume_input.get("approval_request_id") - or resume_input.get("interrupt_id") - or resume_input.get("id") - or "" - ), - } - if resume_input.get("reason") is not None: - decision["reason"] = str(resume_input.get("reason") or "") - return decision - - -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 ""), - ) - if event_type != "approval_response": - continue - resume_input = (event.metadata or {}).get("resume_input") - if not isinstance(resume_input, Mapping): - break - decision = resume_input.get("approval") - if not isinstance(decision, Mapping): - decision = _approval_decision_from_resume(resume_input) - if bool(decision.get("approved") or decision.get("approve")): - break - denials += 1 - return denials - - -def _normalize_approval_resume_input( - resume_input: Mapping[str, Any], - events: Sequence[SessionEvent], - *, - include_resolved: bool = False, -) -> dict[str, Any]: - normalized = dict(resume_input) - if not _is_approval_resume_input(normalized): - return normalized - - approval_request_id = str( - normalized.get("approval_request_id") or normalized.get("interrupt_id") or "" - ) - pending_events = ( - _approval_request_events(events) if include_resolved else _pending_approval_events(events) - ) - matched_event = None - for event in reversed(pending_events): - if not approval_request_id or _approval_request_id_from_event(event) == approval_request_id: - matched_event = event - break - if matched_event is None: - return normalized - - interrupt_info = (matched_event.metadata or {}).get("interrupt_info") - if not isinstance(interrupt_info, Mapping): - return normalized - if not (interrupt_info.get("tool_name") or normalized.get("tool_name")): - return normalized - - decision = _approval_decision_from_resume(normalized) - tool_args = _parse_approval_arguments( - interrupt_info.get("arguments") - or interrupt_info.get("tool_args") - or interrupt_info.get("args") - or {} - ) - tool_args["approval"] = decision - - if not normalized.get("approval_request_id"): - request_id = _approval_request_id_from_event(matched_event) - if request_id: - normalized["approval_request_id"] = request_id - decision["approval_request_id"] = request_id - if interrupt_info.get("tool_name") and not normalized.get("tool_name"): - normalized["tool_name"] = str(interrupt_info.get("tool_name")) - if interrupt_info.get("run_id") and not normalized.get("run_id"): - normalized["run_id"] = str(interrupt_info.get("run_id")) - normalized["approval"] = decision - normalized["tool_args"] = tool_args - return normalized - - -def _builtin_tool_callable(tool_name: str): - name = str(tool_name or "").strip() - if not name: - return None - if name in { - "write_workspace_file", - "write_workspace_files", - "delete_workspace_file", - }: - from ksadk.toolsets import workspace - return getattr(workspace, name, None) - if name == "execute_skills": - from ksadk.toolsets.skills import execute_skills - return execute_skills - if name in {"run_command", "run_code"}: - from ksadk.toolsets import sandbox - - return getattr(sandbox, name, None) - return None - - -def _tool_receipt_metadata( - *, - session_id: str, - run_id: str, - tool_name: str, - tool_args: Mapping[str, Any], - tool_call_id: str | None = None, - checkpoint_id: str | None = None, - framework: str | None = None, - framework_ref: Mapping[str, Any] | None = None, - status: str = "completed", -) -> dict[str, Any]: - idempotency_key = build_tool_receipt_idempotency_key( - session_id=session_id, - run_id=run_id, - checkpoint_id=checkpoint_id, - tool_call_id=tool_call_id, - tool_name=tool_name, - tool_args=tool_args, - ) - return { - "receipt_id": f"tr_{idempotency_key.removeprefix('tool_receipt:')[:24]}", - "idempotency_key": idempotency_key, - "tool_name": tool_name, - "tool_call_id": tool_call_id or run_id, - "run_id": run_id, - "checkpoint_id": checkpoint_id or "", - "framework": framework or "", - "framework_ref": dict(framework_ref or {}), - "status": status, - "created_at": time.time(), - } - - -def _tool_receipt_status_from_output(output: Any) -> str: - if not isinstance(output, Mapping): - return "failed" - status = str(output.get("status") or "").strip().lower() - if status == "accepted_not_extracted": - return "completed" - return "completed" if output.get("ok") is not False else "failed" - - -def _tool_resume_run_id(resume_input: Mapping[str, Any]) -> str: - return str( - resume_input.get("run_id") - or resume_input.get("call_id") - or resume_input.get("approval_request_id") - or resume_input.get("interrupt_id") - or "" - ) - - -def _tool_receipt_idempotency_key_for_resume( - *, - session_id: str, - resume_input: Mapping[str, Any], -) -> str | None: - tool_name = str(resume_input.get("tool_name") or "").strip() - if not tool_name: - return None - tool_args = resume_input.get("tool_args") - if not isinstance(tool_args, Mapping): - return None - run_id = _tool_resume_run_id(resume_input) - if not run_id: - return None - return build_tool_receipt_idempotency_key( - session_id=session_id, - run_id=run_id, - tool_call_id=run_id, - tool_name=tool_name, - tool_args=dict(tool_args), - ) - - -def _find_tool_receipt_event_by_key( - events: Sequence[SessionEvent], - idempotency_key: str, -) -> SessionEvent | None: - for event in reversed(events): - if event.event_type != "tool_result": - continue - metadata = event.metadata or {} - receipt = metadata.get("tool_receipt") - if not isinstance(receipt, Mapping): - continue - if str(receipt.get("idempotency_key") or "") == idempotency_key: - return event - return None - - -def _latest_checkpoint_metadata_for_run( - events: Sequence[SessionEvent], - run_id: str, -) -> dict[str, Any]: - normalized_run_id = str(run_id or "").strip() - if not normalized_run_id: - return {} - for event in reversed(events): - if event.event_type != "run_checkpoint": - continue - metadata = event.metadata or {} - if str(metadata.get("run_id") or "").strip() != normalized_run_id: - continue - checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() - if not checkpoint_id: - continue - framework_ref = metadata.get("framework_ref") - return { - "checkpoint_id": checkpoint_id, - "framework": str(metadata.get("framework") or ""), - "framework_ref": dict(framework_ref) if isinstance(framework_ref, Mapping) else {}, - } - return {} - - -async def _execute_approved_builtin_tool_resume( - *, - session_id: str, - invocation_id: str, - resume_input: Mapping[str, Any], - session_service_provider: Callable[[], Any], -) -> dict[str, Any] | None: - approval = resume_input.get("approval") - if not isinstance(approval, Mapping) or not bool(approval.get("approved")): - return None - tool_name = str(resume_input.get("tool_name") or "").strip() - tool_func = _builtin_tool_callable(tool_name) - if tool_func is None: - return None - - tool_args = resume_input.get("tool_args") - if not isinstance(tool_args, Mapping): - return None - call_args = dict(tool_args) - run_id = _tool_resume_run_id(resume_input) - service = session_service_provider() - existing_events = await service.get_events(session_id) - checkpoint_metadata = _latest_checkpoint_metadata_for_run(existing_events, run_id) - receipt = _tool_receipt_metadata( - session_id=session_id, - run_id=run_id, - tool_call_id=run_id, - tool_name=tool_name, - tool_args=call_args, - checkpoint_id=checkpoint_metadata.get("checkpoint_id"), - framework=checkpoint_metadata.get("framework"), - framework_ref=checkpoint_metadata.get("framework_ref"), - ) - existing_event = _find_tool_receipt_event_by_key( - existing_events, - receipt["idempotency_key"], - ) - if existing_event is not None: - existing_metadata = existing_event.metadata or {} - output = existing_metadata.get("tool_output", "") - if isinstance(output, Mapping): - output = {**dict(output), "replayed": True} - replayed_receipt = { - **dict((existing_metadata.get("tool_receipt") or receipt)), - "replayed": True, - "replayed_from_event_id": existing_event.id, - } - await append_conversation_event( - session_id=session_id, - author="tool", - role="user", - text=str(output), - invocation_id=invocation_id, - event_type="tool_result", - session_service_provider=session_service_provider, - metadata={ - "tool_name": tool_name, - "tool_args": call_args, - "tool_output": output, - "run_id": run_id, - "approval_request_id": resume_input.get("approval_request_id") - or resume_input.get("interrupt_id"), - "tool_receipt": replayed_receipt, - "replayed": True, - }, - ) - return { - "type": "function_call_output", - "call_id": run_id, - "output": output, - } - - try: - output = tool_func(**call_args) - except Exception as exc: - output = {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} - - receipt["status"] = _tool_receipt_status_from_output(output) - await append_conversation_event( - session_id=session_id, - author="tool", - role="user", - text=str(output), - invocation_id=invocation_id, - event_type="tool_result", - session_service_provider=session_service_provider, - metadata={ - "tool_name": tool_name, - "tool_args": call_args, - "tool_output": output, - "run_id": run_id, - "approval_request_id": resume_input.get("approval_request_id") - or resume_input.get("interrupt_id"), - "tool_receipt": receipt, - }, - ) - return { - "type": "function_call_output", - "call_id": run_id, - "output": output, - } - - -def _is_approval_resume_input(resume_input: Mapping[str, Any]) -> bool: - return str(resume_input.get("type") or "").strip() in { - "mcp_approval_response", - "ksadk_resume", - "ksadk.approval_response", - } - - -def _is_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> bool: - return str(resume_input.get("type") or "").strip() == "agentengine.resume_checkpoint" - - -def _failed_status_for_resume(resume_input: Mapping[str, Any] | None) -> str: - """checkpoint resume 失败时返回 resume_failed,否则返回 failed。 - - 仅 checkpoint resume 的失败才写 resume_failed(独立终态,触发 SSE [DONE] - 并让前端展示"恢复失败");approval/ksadk_resume 等其他 resume 失败仍写 failed。 - """ - if resume_input is not None and _is_checkpoint_resume_input(resume_input): - return "resume_failed" - return "failed" - - -def _normalize_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> dict[str, Any]: - run_id = str(resume_input.get("run_id") or "").strip() - if not run_id: - raise ValueError("Checkpoint resume requires run_id") - - checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() - if not checkpoint_id: - raise ValueError("Checkpoint resume requires checkpoint_id") - - framework = str(resume_input.get("framework") or "langgraph").strip() or "langgraph" - raw_framework_ref = resume_input.get("framework_ref") - framework_ref = dict(raw_framework_ref) if isinstance(raw_framework_ref, Mapping) else {} - raw_framework_detail = framework_ref.get(framework) - framework_detail = ( - dict(raw_framework_detail) if isinstance(raw_framework_detail, Mapping) else {} - ) - framework_detail.setdefault("checkpoint_id", checkpoint_id) - if resume_input.get("thread_id") and not framework_detail.get("thread_id"): - framework_detail["thread_id"] = str(resume_input.get("thread_id")) - framework_ref[framework] = framework_detail - - resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip() - if not resume_attempt_id: - resume_attempt_id = f"resume_{uuid.uuid4().hex}" - - return { - "type": "agentengine.resume_checkpoint", - "run_id": run_id, - "checkpoint_id": checkpoint_id, - "resume_attempt_id": resume_attempt_id, - "framework": framework, - "framework_ref": framework_ref, - "metadata": dict(resume_input.get("metadata") or resume_input.get("Metadata") or {}), - "checkpoint_metadata": dict( - resume_input.get("checkpoint_metadata") - or resume_input.get("CheckpointMetadata") - or resume_input.get("metadata") - or resume_input.get("Metadata") - or {} - ), - "resume_instruction_enabled": bool( - resume_input.get("resume_instruction_enabled") - or resume_input.get("ResumeInstructionEnabled") - ), - "resume_instruction": str( - resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or "" - ).strip(), - } - - -def _agentengine_resume_metadata(resume_input: Mapping[str, Any] | None) -> dict[str, Any]: - if not resume_input or not _is_checkpoint_resume_input(resume_input): - return {} - return { - "agentengine": { - "action": "resume_checkpoint", - "run_id": str(resume_input.get("run_id") or ""), - "checkpoint_id": str(resume_input.get("checkpoint_id") or ""), - "resume_attempt_id": str(resume_input.get("resume_attempt_id") or ""), - "framework": str(resume_input.get("framework") or ""), - "framework_ref": dict(resume_input.get("framework_ref") or {}), - } - } - - -def _extract_agentengine_metadata(result: Mapping[str, Any] | None) -> dict[str, Any]: - if not result: - return {} - metadata = result.get("metadata") - if not isinstance(metadata, Mapping): - return {} - agentengine = metadata.get("agentengine") - if not isinstance(agentengine, Mapping): - return {} - return {"agentengine": dict(agentengine)} - - -def _checkpoint_event_args_from_agentengine_metadata( - agentengine_metadata: Mapping[str, Any] | None, - *, - fallback_run_id: str, -) -> dict[str, Any] | None: - if not isinstance(agentengine_metadata, Mapping): - return None - framework = str(agentengine_metadata.get("framework") or "langgraph").strip() or "langgraph" - raw_framework_ref = agentengine_metadata.get("framework_ref") - if not isinstance(raw_framework_ref, Mapping): - return None - framework_ref = dict(raw_framework_ref) - raw_framework_detail = framework_ref.get(framework) - if not isinstance(raw_framework_detail, Mapping): - return None - framework_detail = dict(raw_framework_detail) - checkpoint_id = str(framework_detail.get("checkpoint_id") or "").strip() - if not checkpoint_id: - return None - run_id = str(agentengine_metadata.get("run_id") or fallback_run_id or "").strip() - if not run_id: - return None - phase = str(agentengine_metadata.get("phase") or "").strip() - display_metadata = { - key: value - for key, value in agentengine_metadata.items() - if key - not in { - "run_id", - "checkpoint_id", - "framework", - "framework_ref", - "phase", - } - } - return { - "run_id": run_id, - "checkpoint_id": checkpoint_id, - "framework": framework, - "framework_ref": framework_ref, - "phase": phase, - "metadata": display_metadata, - } - - -def _merge_agentengine_metadata( - *metadata_items: Mapping[str, Any] | None, -) -> dict[str, Any]: - merged: dict[str, Any] = {} - for metadata in metadata_items: - if not isinstance(metadata, Mapping): - continue - agentengine = metadata.get("agentengine") - if not isinstance(agentengine, Mapping): - continue - next_agentengine = dict(agentengine) - merged.update(next_agentengine) - if isinstance(agentengine.get("framework_ref"), Mapping): - existing_framework_ref = ( - merged.get("framework_ref") - if isinstance(merged.get("framework_ref"), Mapping) - else {} - ) - merged["framework_ref"] = { - **dict(existing_framework_ref), - **dict(agentengine.get("framework_ref") or {}), - } - return {"agentengine": merged} if merged else {} - - -def _format_resume_response_text(resume_input: Mapping[str, Any]) -> str: - item_type = str(resume_input.get("type") or "resume") - if item_type == "mcp_approval_response": - approval_request_id = str(resume_input.get("approval_request_id") or "") - approve = resume_input.get("approve") - reason = str(resume_input.get("reason") or "").strip() - parts = [f"mcp_approval_response approval_request_id={approval_request_id}"] - if approve is not None: - parts.append(f"approve={bool(approve)}") - if reason: - parts.append(f"reason={reason}") - return " ".join(parts) - - if item_type == "function_call_output": - output = resume_input.get("output", "") - if isinstance(output, (dict, list)): - output_text = json.dumps(output, ensure_ascii=False, sort_keys=True) - else: - output_text = str(output) - return ( - f"function_call_output call_id={resume_input.get('call_id') or ''} output={output_text}" - ) - - return f"{item_type} {json.dumps(dict(resume_input), ensure_ascii=False, sort_keys=True)}" - - -def _stringify_responses_item_value(value: Any) -> str: - if value is None: - return "" - if isinstance(value, str): - return value - if isinstance(value, (dict, list)): - return json.dumps(value, ensure_ascii=False, indent=2) - return str(value) - - -def _responses_output_item_text(item: Mapping[str, Any]) -> str: - for text_field in ("output_text", "text", "summary_text", "delta"): - value = item.get(text_field) - if isinstance(value, str) and value: - return value - summary = item.get("summary") - if isinstance(summary, Sequence) and not isinstance(summary, (str, bytes, bytearray)): - parts: list[str] = [] - for part in summary: - if isinstance(part, str): - parts.append(part) - elif isinstance(part, Mapping): - text = part.get("text") or part.get("summary_text") - if isinstance(text, str): - parts.append(text) - if parts: - return "".join(parts) - content = item.get("content") - if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)): - parts = [] - for part in content: - if isinstance(part, str): - parts.append(part) - elif isinstance(part, Mapping): - text = part.get("text") - if isinstance(text, str): - parts.append(text) - return "".join(parts) - if isinstance(content, str): - return content - return "" - - -def _semantic_events_from_responses_output(output: Sequence[Any]) -> list[dict[str, Any]]: - events: list[dict[str, Any]] = [] - tool_names: dict[str, str] = {} - for raw_item in output: - if not isinstance(raw_item, Mapping): - continue - item = dict(raw_item) - item_type = str(item.get("type") or "").strip() - item_id = str(item.get("id") or item.get("item_id") or "") - call_id = str(item.get("call_id") or "") - if item_type == "function_call": - name = str(item.get("name") or item.get("tool_name") or "tool") - args = _stringify_responses_item_value( - item.get("arguments") - if "arguments" in item - else item.get("args", item.get("input")) - ) - if item_id: - tool_names[item_id] = name - if call_id: - tool_names[call_id] = name - events.append( - { - "type": "tool_call", - "name": name, - "args": args, - "run_id": call_id or item_id or None, - } - ) - continue - if item_type == "function_call_output": - name = ( - tool_names.get(call_id) - or tool_names.get(item_id) - or str(item.get("name") or "tool") - ) - output_text = _stringify_responses_item_value( - item.get("output") if "output" in item else item.get("result", item.get("content")) - ) - events.append( - { - "type": "tool_result", - "name": name, - "output": output_text, - "run_id": call_id or item_id or None, - } - ) - continue - if item_type in {"reasoning", "reasoning_summary", "reasoning_summary_text"}: - text = _responses_output_item_text(item) - if text: - events.append({"type": "thinking", "delta": text}) - return events - - -def _model_options_disable_reasoning(model_options: Mapping[str, Any] | None) -> bool: - normalized = normalize_model_options(model_options) - reasoning = normalized.get("reasoning") - if isinstance(reasoning, Mapping): - effort = str(reasoning.get("effort") or "").strip().lower() - if effort in {"none", "off", "disabled", "disable", "false", "0"}: - return True - max_reasoning_tokens = normalized.get("max_reasoning_tokens") - if max_reasoning_tokens is not None: - try: - return int(max_reasoning_tokens) <= 0 - except (TypeError, ValueError): - pass - thinking = normalized.get("thinking") - if isinstance(thinking, Mapping): - raw_type = str(thinking.get("type") or thinking.get("status") or "").strip().lower() - return raw_type in {"disabled", "disable", "off", "none", "false", "0"} - if isinstance(thinking, bool): - return not thinking - raw_thinking = str(thinking or "").strip().lower() - return raw_thinking in {"disabled", "disable", "off", "none", "false", "0"} - - -def _filter_responses_reasoning_output(output: Sequence[Any]) -> list[Any]: - filtered: list[Any] = [] - for raw_item in output: - if isinstance(raw_item, Mapping): - item_type = str(raw_item.get("type") or "").strip() - if item_type in {"reasoning", "reasoning_summary", "reasoning_summary_text"}: - continue - filtered.append(raw_item) - return filtered - - -def _truncate_text(text: str | None, limit: int) -> str: - raw = " ".join(str(text or "").strip().split()) - if len(raw) <= limit: - return raw - return f"{raw[: max(limit - 1, 0)].rstrip()}…" - - -async def _update_session_metadata_after_user_turn( - *, - service: Any, - session: Session, - user_input: str, -) -> None: - text = _truncate_text(user_input, SESSION_SUMMARY_MAX_CHARS) - if not text: - return - updates: dict[str, str] = {"last_prompt": text} - if not (session.first_prompt or "").strip(): - updates["first_prompt"] = text - if not (session.title or "").strip(): - updates["title"] = build_fallback_title(session.first_prompt or text) - updates["title_source"] = "fallback_first_prompt" - await service.update_session_metadata(session.id, **updates) - - -async def prime_session_metadata_for_user_turn( - *, - service: Any, - session: Session, - messages: Sequence[Mapping[str, Any]] | None = None, - user_input: str | None = None, -) -> None: - text = str(user_input or "").strip() - if not text and messages: - text, _display, _content, _parts, _attachments, _attachment_results = _latest_user_turn( - messages - ) - await _update_session_metadata_after_user_turn( - service=service, - session=session, - user_input=text, - ) - - -async def _update_session_metadata_after_assistant_turn( - *, - service: Any, - session_id: str, - assistant_text: str, - model: str | None, -) -> None: - summary = _truncate_text(strip_reasoning_markup(assistant_text), SESSION_SUMMARY_MAX_CHARS) - if summary: - await service.update_session_metadata(session_id, summary=summary) - - session = await service.get_session(session_id) - if not session: - return - if (session.title_source or "").strip() != "fallback_first_prompt": - return - first_prompt = str(session.first_prompt or "").strip() - if not first_prompt or not summary: - return - - next_title = build_heuristic_title(first_prompt=first_prompt, assistant_text=summary) - next_title_source = ( - HEURISTIC_SESSION_TITLE_SOURCE - if next_title and next_title != (session.title or "").strip() - else "" - ) - if next_title and next_title != (session.title or "").strip(): - await service.update_session_metadata( - session_id, - title=next_title, - title_source=next_title_source, - ) - - title_client = resolve_session_title_client() - title_model = resolve_session_title_model(model) - if title_client.is_available and title_model: - asyncio.create_task( - _refine_session_title_in_background( - service=service, - session_id=session_id, - first_prompt=first_prompt, - assistant_text=summary, - model=title_model, - ) - ) - - -async def _refine_session_title_in_background( - *, - service: Any, - session_id: str, - first_prompt: str, - assistant_text: str, - model: str, -) -> None: - title_client = resolve_session_title_client() - try: - title, _usage = await title_client.generate_title( - model=model, - messages=build_session_title_messages( - first_prompt=first_prompt, - assistant_text=assistant_text, - ), - timeout_ms=DEFAULT_SESSION_TITLE_TIMEOUT_MS, - ) - except Exception: - logger.debug("failed to refine session title", exc_info=True) - return - - if not title or is_low_quality_title(title, first_prompt=first_prompt): - return - session = await service.get_session(session_id) - if not session: - return - if title == (session.title or "").strip(): - return - await service.update_session_metadata( - session_id, - title=title, - title_source="ai", - ) - - -def _resolve_model_metadata( - model: Optional[str], - *, - model_metadata: Mapping[str, Any] | None = None, -) -> dict[str, Any]: - """统一收口模型上下文配置。 - - 当前阶段还没把远端 /v1/models 的完整 metadata 缓存接进 runtime, - 所以这里只用默认值 + model id。后续模型目录接口上线 richer metadata - 后,只需要把这层改成真正的 resolver,compaction 逻辑本身不用再动。 - """ - - if isinstance(model_metadata, Mapping): - resolved = dict(model_metadata) - if model and not str(resolved.get("id") or "").strip(): - resolved["id"] = model - return normalize_model_metadata(resolved) - return normalize_model_metadata({"id": model or "agent"}) - - -def _model_catalog_endpoint(api_base: str) -> str: - base_url = str(api_base or "").rstrip("/") - if not base_url: - return "" - if base_url.endswith("/v1"): - return f"{base_url}/models" - return f"{base_url}/v1/models" - - -async def _fetch_remote_model_catalog(api_base: str, api_key: str) -> list[dict[str, Any]]: - url = _model_catalog_endpoint(api_base) - if not url: - return [] - - headers = {} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - async with httpx.AsyncClient(verify=False, timeout=10) as client: - response = await client.get(url, headers=headers) - response.raise_for_status() - payload = response.json() - - raw_models = payload if isinstance(payload, list) else list(payload.get("data", [])) - normalized: list[dict[str, Any]] = [] - for item in raw_models: - if isinstance(item, Mapping) or isinstance(item, str): - normalized.append(normalize_model_metadata(item)) - return normalized - - -async def _resolve_runtime_model_metadata( - model: Optional[str], - *, - model_metadata: Mapping[str, Any] | None = None, -) -> dict[str, Any]: - resolved = _resolve_model_metadata(model, model_metadata=model_metadata) - if isinstance(model_metadata, Mapping) or not model: - return resolved - - api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "" - if not api_base: - return resolved - - api_key = os.getenv("OPENAI_API_KEY", "") - cache_key = (api_base.rstrip("/"), api_key) - now = time.monotonic() - cached = _MODEL_CATALOG_CACHE.get(cache_key) - models: list[dict[str, Any]] - if cached and (now - cached[0]) < _MODEL_CATALOG_CACHE_TTL_SECONDS: - models = cached[1] - else: - try: - models = await _fetch_remote_model_catalog(api_base, api_key) - _MODEL_CATALOG_CACHE[cache_key] = (now, models) - except Exception as exc: - logger.debug("Failed to fetch remote model metadata for %s: %s", model, exc) - return resolved - - target = str(model).strip() - for item in models: - if str(item.get("id") or "").strip() == target: - return item - return resolved - - -def _normalized_conversation_messages(messages: Sequence[Dict[str, Any]]) -> list[dict[str, Any]]: - """把不同入口的 message 形态收敛成统一内部格式。""" - - normalized_messages: list[dict[str, Any]] = [] - for message in list(messages or []): - if isinstance(message, dict) and any( - key in message - for key in ("display_content", "attachments", "attachment_results", "parts") - ): - normalized_messages.append( - { - "role": str(message.get("role") or "user"), - "content": str(message.get("content") or ""), - "display_content": str( - message.get("display_content") or message.get("content") or "" - ), - "parts": list(message.get("parts") or []), - "input_content": list( - message.get("input_content") - or canonical_input_content_from_parts(list(message.get("parts") or [])) - ), - "attachments": list(message.get("attachments") or []), - "attachment_results": list(message.get("attachment_results") or []), - } - ) - continue - normalized_messages.extend(normalize_kop_messages([message])) - return normalized_messages - - -def _latest_user_turn( - normalized_messages: Sequence[Dict[str, Any]], -) -> tuple[ - str, - str, - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], -]: - latest_user_message = next( - (message for message in reversed(normalized_messages) if message.get("role") == "user"), - {}, - ) - user_input = str(latest_user_message.get("content") or "") - user_display_input = str(latest_user_message.get("display_content") or user_input) - input_content = list(latest_user_message.get("input_content") or []) - user_parts = list(latest_user_message.get("parts") or []) - attachments = list(latest_user_message.get("attachments") or []) - attachment_results = list(latest_user_message.get("attachment_results") or []) - return ( - user_input, - user_display_input, - input_content, - user_parts, - attachments, - attachment_results, - ) - - -def _canonical_input_messages( - normalized_messages: Sequence[Dict[str, Any]], -) -> list[dict[str, Any]]: - input_messages: list[dict[str, Any]] = [] - for message in normalized_messages or []: - role = str(message.get("role") or "user") - content = list(message.get("input_content") or []) - if not content: - text = str(message.get("content") or "") - if text: - content = [{"type": "input_text", "text": text}] - input_messages.append({"role": role, "content": content}) - return input_messages - - -def _parts_include_file(parts: Sequence[dict[str, Any]]) -> bool: - for part in parts or []: - if not isinstance(part, dict): - continue - if part.get("inlineData") is not None or part.get("fileData") is not None: - return True - return False - - -def _latest_attachment_context_from_messages( - normalized_messages: Sequence[Dict[str, Any]], -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - attachments: list[dict[str, Any]] = [] - attachment_results: list[dict[str, Any]] = [] - for message in normalized_messages: - if str(message.get("role") or "user") != "user": - continue - message_attachments = list(message.get("attachments") or []) - message_attachment_results = list(message.get("attachment_results") or []) - if message_attachments or message_attachment_results: - attachments = message_attachments - attachment_results = message_attachment_results - return attachments, attachment_results - - -def _attachment_context_from_session( - session: Session | None, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - state = getattr(session, "state", None) or {} - payload = state.get(ATTACHMENT_CONTEXT_STATE_KEY) - if not isinstance(payload, dict): - return [], [] - return ( - [ - compact_attachment_for_session(item) - for item in payload.get("attachments") or [] - if isinstance(item, dict) - ], - [ - compact_attachment_result_for_session(item) - for item in payload.get("attachment_results") or [] - if isinstance(item, dict) - ], - ) - - -def _build_attachment_context_state_delta( - *, - base_state_delta: dict[str, Any] | None, - attachments: Sequence[dict[str, Any]], - attachment_results: Sequence[dict[str, Any]], -) -> dict[str, Any]: - merged = dict(base_state_delta or {}) - if attachments or attachment_results: - merged[ATTACHMENT_CONTEXT_STATE_KEY] = { - "attachments": [ - compact_attachment_for_session(item) - for item in attachments - if isinstance(item, dict) - ], - "attachment_results": [ - compact_attachment_result_for_session(item) - for item in attachment_results - if isinstance(item, dict) - ], - } - return merged - - -def _resolve_effective_attachment_context( - *, - normalized_messages: Sequence[Dict[str, Any]], - session: Session | None, -) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - message_attachments, message_attachment_results = _latest_attachment_context_from_messages( - normalized_messages - ) - if message_attachments or message_attachment_results: - return message_attachments, message_attachment_results - - session_attachments, session_attachment_results = _attachment_context_from_session(session) - return session_attachments, session_attachment_results - - -def _transcript_event_type(event: SessionEvent) -> str: - return canonical_event_type( - event.event_type, - author=event.author, - role=str((event.content or {}).get("role") or ""), - ) - - -def _build_pending_user_event( - *, - session_id: str, - invocation_id: str, - user_input: str, - user_display_input: str, - attachments: Sequence[dict[str, Any]], - attachment_results: Sequence[dict[str, Any]], -) -> SessionEvent: - """构造一条未落库的用户事件,专供 compaction 预览使用。""" - - return SessionEvent.from_dict( - { - "id": f"preview-{uuid.uuid4()}", - "author": "user", - "event_type": "user_message", - "invocationId": invocation_id, - "content": {"role": "user", "parts": [{"text": user_display_input or user_input}]}, - "timestamp": int(time.time() * 1000), - "metadata": { - "agent_input": user_input, - "attachments": [ - compact_attachment_for_session(item) for item in attachments if item - ], - "attachment_results": [ - compact_attachment_result_for_session(item) - for item in attachment_results - if item - ], - }, - "stateDelta": {}, - }, - session_id=session_id, - ) - - -def _user_event_content( - *, - user_input: str, - user_display_input: str, - input_content: Sequence[dict[str, Any]], - user_parts: Sequence[dict[str, Any]], -) -> dict[str, Any]: - parts = list(input_content or []) - if not parts: - parts = canonical_input_content_from_parts(list(user_parts or [])) - if not parts: - text = user_display_input or user_input - parts = [{"text": text}] if text else [] - return {"role": "user", "parts": parts} - - -def _plan_compaction( - events: Sequence[SessionEvent], - *, - model: Optional[str] = None, - model_metadata: Mapping[str, Any] | None = None, - pending_events: Sequence[SessionEvent] | None = None, - force: bool = False, - keep_tail_groups: int | None = None, -) -> CompactionPlan: - """根据当前 transcript 计算是否需要做 checkpoint compaction。""" - - compacted_until = compacted_until_seq_id(list(events)) - transcript_events = [ - event - for event in events - if event.seq_id > compacted_until - and _transcript_event_type(event) in TRANSCRIPT_EVENT_TYPES - and _transcript_event_type(event) != "context_checkpoint" - ] - pending_transcript_events = [ - event - for event in (pending_events or []) - if _transcript_event_type(event) in TRANSCRIPT_EVENT_TYPES - and _transcript_event_type(event) != "context_checkpoint" - ] - combined_events = [*transcript_events, *pending_transcript_events] - groups = group_events_by_api_round(combined_events) - pinned_group_indexes = sorted(find_pinned_group_indexes(groups)) - pinned_state = extract_pinned_state(groups) - tail_groups = ( - keep_tail_groups - if keep_tail_groups is not None - else (PTL_RETRY_KEEP_TAIL_GROUPS if force else AUTOCOMPACT_KEEP_TAIL_GROUPS) - ) - resolved_model_metadata = _resolve_model_metadata(model, model_metadata=model_metadata) - auto_compact_threshold_tokens = get_auto_compact_threshold_tokens(resolved_model_metadata) - auto_compact_threshold_percentage = get_auto_compact_threshold_percentage( - resolved_model_metadata - ) - total_chars = sum(len(extract_event_text(event)) for event in combined_events) - total_estimated_tokens = sum( - estimate_text_tokens(extract_event_text(event)) for event in combined_events - ) - if not force and ( - len(groups) <= tail_groups or total_estimated_tokens <= auto_compact_threshold_tokens - ): - return CompactionPlan( - should_compact=False, - groups_to_compact=[], - total_chars=total_chars, - total_estimated_tokens=total_estimated_tokens, - group_count=len(groups), - tail_groups=tail_groups, - auto_compact_threshold_tokens=auto_compact_threshold_tokens, - auto_compact_threshold_percentage=auto_compact_threshold_percentage, - pinned_group_indexes=pinned_group_indexes, - pinned_state=pinned_state, - ) - - compactable_indexes = [ - index for index in range(len(groups)) if index not in pinned_group_indexes - ] - retained_tail_indexes = set(compactable_indexes[-tail_groups:]) if tail_groups > 0 else set() - preserved_indexes = set(pinned_group_indexes) | retained_tail_indexes - first_preserved_index = min(preserved_indexes) if preserved_indexes else len(groups) - groups_to_compact = [ - group - for index, group in enumerate(groups[:first_preserved_index]) - if index not in pinned_group_indexes - ] - if not groups_to_compact: - return CompactionPlan( - should_compact=False, - groups_to_compact=[], - total_chars=total_chars, - total_estimated_tokens=total_estimated_tokens, - group_count=len(groups), - tail_groups=tail_groups, - auto_compact_threshold_tokens=auto_compact_threshold_tokens, - auto_compact_threshold_percentage=auto_compact_threshold_percentage, - pinned_group_indexes=pinned_group_indexes, - pinned_state=pinned_state, - ) - - compacted_until_seq_id_value = groups_to_compact[-1][-1].seq_id or None - return CompactionPlan( - should_compact=True, - groups_to_compact=groups_to_compact, - total_chars=total_chars, - total_estimated_tokens=total_estimated_tokens, - group_count=len(groups), - tail_groups=tail_groups, - auto_compact_threshold_tokens=auto_compact_threshold_tokens, - auto_compact_threshold_percentage=auto_compact_threshold_percentage, - compacted_until_seq_id=compacted_until_seq_id_value, - pinned_group_indexes=pinned_group_indexes, - pinned_state=pinned_state, - ) - - -async def preview_auto_compaction( - *, - agent_id: str, - user_id: str, - session_id: Optional[str], - messages: Sequence[Dict[str, Any]], - model: Optional[str] = None, - model_metadata: Mapping[str, Any] | None = None, - session_service_provider: Callable[[], Any] | None = None, -) -> CompactionPlan: - """在真正写入 turn 之前预估是否会触发自动压缩。 - - 这个预览只用于给 UI 提前打一条“正在压缩上下文”的流式提示,不会修改会话。 - """ - - if not session_id: - return CompactionPlan( - should_compact=False, - groups_to_compact=[], - total_chars=0, - total_estimated_tokens=0, - group_count=0, - tail_groups=AUTOCOMPACT_KEEP_TAIL_GROUPS, - ) - - provider = session_service_provider or resolve_session_service - service = provider() - existing_session = await service.get_session(session_id) - if not existing_session: - return CompactionPlan( - should_compact=False, - groups_to_compact=[], - total_chars=0, - total_estimated_tokens=0, - group_count=0, - tail_groups=AUTOCOMPACT_KEEP_TAIL_GROUPS, - ) - - resolved_user_id = existing_session.user_id or user_id - if existing_session.agent_id != agent_id or resolved_user_id != user_id: - return CompactionPlan( - should_compact=False, - groups_to_compact=[], - total_chars=0, - total_estimated_tokens=0, - group_count=0, - tail_groups=AUTOCOMPACT_KEEP_TAIL_GROUPS, - ) - - normalized_messages = _normalized_conversation_messages(messages) - resolved_model_metadata = await _resolve_runtime_model_metadata( - model, - model_metadata=model_metadata, - ) - user_input, user_display_input, _, _, attachments, attachment_results = _latest_user_turn( - normalized_messages - ) - effective_attachments, effective_attachment_results = _resolve_effective_attachment_context( - normalized_messages=normalized_messages, - session=existing_session, - ) - pending_event = _build_pending_user_event( - session_id=session_id, - invocation_id=f"preview-{uuid.uuid4()}", - user_input=user_input, - user_display_input=user_display_input or user_input, - attachments=effective_attachments, - attachment_results=effective_attachment_results, - ) - events = await service.get_events(session_id) - return _plan_compaction( - events, - model=model, - model_metadata=resolved_model_metadata, - pending_events=[pending_event], - ) - - -async def ensure_conversation_session( - *, - agent_id: str, - user_id: str, - session_id: Optional[str], - session_service_provider: Callable[[], Any] | None = None, -) -> Session: - """确保会话存在,并在显式 session_id 冲突时做 owner 校验。""" - service = (session_service_provider or resolve_session_service)() - if session_id: - existing = await service.get_session(session_id) - if existing: - if existing.agent_id != agent_id or existing.user_id != user_id: - raise HTTPException( - status_code=409, - detail="Session id belongs to a different agent or user", - ) - return existing - return await service.create_session(agent_id, user_id, session_id=session_id) - return await service.create_session(agent_id, user_id) - - -async def append_conversation_event( - *, - session_id: str, - author: str, - role: str, - text: str, - invocation_id: Optional[str] = None, - state_delta: Optional[dict[str, Any]] = None, - metadata: Optional[dict[str, Any]] = None, - event_type: Optional[str] = None, - content: Optional[dict[str, Any]] = None, - session_service_provider: Callable[[], Any] | None = None, -) -> SessionEvent: - """统一的 canonical event 追加入口。 - - 所有协议层最终都应该落到这里,而不是各自直接 new `SessionEvent`, - 这样 event_type / invocation_id / metadata 的语义才不会再漂移。 - """ - service = (session_service_provider or resolve_session_service)() - payload_content = content if content is not None else {"role": role, "parts": [{"text": text}]} - return await service.append_event( - session_id, - SessionEvent.from_dict( - { - "id": str(uuid.uuid4()), - "author": author, - "event_type": event_type or canonical_event_type(None, author=author, role=role), - "invocationId": invocation_id, - "content": payload_content, - "timestamp": int(time.time() * 1000), - "stateDelta": state_delta or {}, - "metadata": metadata or {}, - }, - session_id=session_id, - ), - ) - - -async def append_run_status_event( - *, - session_id: str, - author: str, - status: str, - invocation_id: Optional[str] = None, - detail: str | None = None, - metadata: Mapping[str, Any] | None = None, - session_service_provider: Callable[[], Any] | None = None, - run_mode: str = RUN_MODE_UNKNOWN, - run_trigger: str = RUN_TRIGGER_UNKNOWN, -) -> SessionEvent: - """记录运行态事件,供 UI/恢复逻辑区分 turn 生命周期。 - - run_mode/run_trigger 是双维度字段(怎么跑/怎么开始),写入 metadata 与 - state_delta.active_run,供前端区分后台长任务、checkpoint 恢复、approval 续跑。 - """ - run_mode = validate_run_mode(run_mode) - run_trigger = validate_run_trigger(run_trigger) - service = (session_service_provider or resolve_session_service)() - if invocation_id: - try: - for event in reversed(await service.get_events(session_id)): - if event.event_type != "run_status" or event.invocation_id != invocation_id: - continue - event_status = str( - (event.metadata or {}).get("status") - or (event.content or {}).get("status") - or "" - ) - if event_status == status: - return event - except Exception: - pass - content = {"status": status} - if detail: - content["detail"] = detail - event_metadata = { - "status": status, - **({"detail": detail} if detail else {}), - "run_mode": run_mode, - "run_trigger": run_trigger, - **dict(metadata or {}), - } - # state_delta.active_run:与 agentengine-server _append_run_status 对齐, - # 让 session.state.active_run 反映当前 run 状态(postgres/local backend 会自动合并)。 - # server 侧 ActiveRunStatus 来源是 state_delta 而非扫事件,不写则 server 在 resume - # 期间仍持旧 active_run 值。run_mode/run_trigger 同步写入,供 _serialize_session 读取。 - state_delta = { - "active_run": { - "invocation_id": invocation_id or "", - "status": status, - "run_mode": run_mode, - "run_trigger": run_trigger, - } - } - return await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text="", - invocation_id=invocation_id, - event_type="run_status", - content=content, - metadata=event_metadata, - state_delta=state_delta, - session_service_provider=lambda: service, - ) - - -async def append_deferred_tools_event( - *, - session_id: str, - author: str, - deferred_tool_names: Sequence[str], - invocation_id: Optional[str] = None, - source_tool_name: str = "tool_search", - session_service_provider: Callable[[], Any] | None = None, - run_mode: str = RUN_MODE_UNKNOWN, - run_trigger: str = RUN_TRIGGER_UNKNOWN, -) -> SessionEvent | None: - names = _extract_deferred_tool_names({"deferred_tool_names": list(deferred_tool_names)}) - if not names: - return None - run_mode = validate_run_mode(run_mode) - run_trigger = validate_run_trigger(run_trigger) - return await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text="", - invocation_id=invocation_id, - event_type="run_status", - content={"status": "in_progress", "detail": "deferred_tools_selected"}, - metadata={ - "status": "in_progress", - "detail": "deferred_tools_selected", - "run_mode": run_mode, - "run_trigger": run_trigger, - "source_tool_name": source_tool_name, - "deferred_tool_names": names, - }, - state_delta={ - "active_run": { - "invocation_id": invocation_id or "", - "status": "in_progress", - "run_mode": run_mode, - "run_trigger": run_trigger, - } - }, - session_service_provider=session_service_provider, - ) - - -async def append_run_checkpoint_event( - *, - session_id: str, - author: str, - run_id: str, - checkpoint_id: str, - framework: str, - framework_ref: Mapping[str, Any], - phase: str = "", - invocation_id: Optional[str] = None, - metadata: Optional[dict[str, Any]] = None, - session_service_provider: Callable[[], Any] | None = None, -) -> SessionEvent: - service = (session_service_provider or resolve_session_service)() - for event in reversed(await service.get_events(session_id)): - if event.event_type != "run_checkpoint": - continue - event_metadata = event.metadata or {} - if ( - str(event_metadata.get("run_id") or "") == str(run_id) - and str(event_metadata.get("checkpoint_id") or "") == str(checkpoint_id) - and str(event_metadata.get("framework") or "") == str(framework) - ): - return event - - event_metadata = dict(metadata or {}) - framework_ref_dict = dict(framework_ref) - langgraph_ref = framework_ref_dict.get("langgraph") - next_node = "" - if isinstance(langgraph_ref, Mapping): - next_node = str(langgraph_ref.get("next_node") or "").strip() - is_terminal = bool(event_metadata.get("is_terminal", False)) - if "is_terminal" not in event_metadata and next_node: - is_terminal = False - raw_is_resumable = event_metadata.get("is_resumable") - is_resumable: bool | None - if isinstance(raw_is_resumable, bool): - is_resumable = raw_is_resumable - else: - is_resumable = None - resume_status = str(event_metadata.get("resume_status") or "").strip() - if not resume_status: - if is_resumable is True: - resume_status = "resumable" - elif is_resumable is False: - resume_status = "disabled" - else: - resume_status = "unknown" - backend = str(event_metadata.get("backend") or "unknown").strip() or "unknown" - scope = str(event_metadata.get("scope") or "unknown").strip() or "unknown" - durable = bool(event_metadata.get("durable", False)) - event_metadata.update( - { - "run_id": str(run_id), - "checkpoint_id": str(checkpoint_id), - "framework": str(framework), - "framework_ref": framework_ref_dict, - "phase": str(phase or ""), - "is_resumable": is_resumable, - "is_terminal": is_terminal, - "resume_status": resume_status, - "resume_disabled_reason": str(event_metadata.get("resume_disabled_reason") or ""), - "next_node": str(event_metadata.get("next_node") or next_node or ""), - "stage_key": str(event_metadata.get("stage_key") or ""), - "stage_name": str( - event_metadata.get("stage_name") - or event_metadata.get("stage") - or event_metadata.get("title") - or "" - ), - "stage_index": event_metadata.get("stage_index"), - "total_stages": event_metadata.get("total_stages"), - "backend": backend, - "scope": scope, - "durable": durable, - "artifact_preview": event_metadata.get("artifact_preview") or {}, - } - ) - return await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text="checkpoint saved", - invocation_id=invocation_id, - event_type="run_checkpoint", - content={ - "status": "checkpointed", - "run_id": str(run_id), - "checkpoint_id": str(checkpoint_id), - "framework": str(framework), - "is_resumable": is_resumable, - "is_terminal": is_terminal, - "resume_status": resume_status, - "resume_disabled_reason": event_metadata["resume_disabled_reason"], - "next_node": event_metadata["next_node"], - "backend": backend, - "scope": scope, - "durable": durable, - **({"phase": str(phase)} if phase else {}), - }, - metadata=event_metadata, - session_service_provider=lambda: service, - ) - - -async def append_run_resume_event( - *, - session_id: str, - author: str, - run_id: str, - checkpoint_id: str, - resume_attempt_id: str, - framework: str, - framework_ref: Mapping[str, Any], - invocation_id: Optional[str] = None, - metadata: Optional[dict[str, Any]] = None, - session_service_provider: Callable[[], Any] | None = None, -) -> SessionEvent: - event_metadata = dict(metadata or {}) - event_metadata.update( - { - "run_id": str(run_id), - "checkpoint_id": str(checkpoint_id), - "resume_attempt_id": str(resume_attempt_id), - "framework": str(framework), - "framework_ref": dict(framework_ref), - } - ) - return await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text="checkpoint resume requested", - invocation_id=invocation_id, - event_type="run_resume", - content={ - "status": "resuming", - "run_id": str(run_id), - "checkpoint_id": str(checkpoint_id), - "resume_attempt_id": str(resume_attempt_id), - "framework": str(framework), - }, - metadata=event_metadata, - session_service_provider=session_service_provider, - ) - - -async def append_reasoning_event( - *, - session_id: str, - author: str, - text: str, - invocation_id: Optional[str] = None, - session_service_provider: Callable[[], Any] | None = None, -) -> SessionEvent | None: - """Persist assistant reasoning so hosted UI refresh can replay thinking state.""" - reasoning_text = str(text or "") - if not reasoning_text: - return None - return await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text=reasoning_text, - invocation_id=invocation_id, - event_type="reasoning", - metadata={"reasoning": reasoning_text}, - session_service_provider=session_service_provider, - ) - - -async def append_context_checkpoint_event( - *, - session_id: str, - author: str, - compacted_until_seq_id: int, - summary_text: str = "", - trigger: str = "auto", - invocation_id: Optional[str] = None, - metadata: Optional[dict[str, Any]] = None, - session_service_provider: Callable[[], Any] | None = None, -) -> SessionEvent: - """追加 compaction boundary + checkpoint summary。 - - 这里遵循 Claude Code 的大方向:边界事件和摘要事件都保留在 transcript - 里,而不是把旧 history 原地覆盖掉。 - """ - event_metadata = dict(metadata or {}) - event_metadata["compacted_until_seq_id"] = compacted_until_seq_id - event_metadata["trigger"] = trigger - await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text="", - invocation_id=invocation_id, - event_type="compaction_boundary", - content={"status": "compacted", "compacted_until_seq_id": compacted_until_seq_id}, - metadata=event_metadata, - session_service_provider=session_service_provider, - ) - return await append_conversation_event( - session_id=session_id, - author=author, - role="model", - text=summary_text, - invocation_id=invocation_id, - event_type="context_checkpoint", - metadata=event_metadata, - session_service_provider=session_service_provider, - ) - - -async def compact_conversation_history( - *, - session_id: str, - author: str, - invocation_id: Optional[str] = None, - model: Optional[str] = None, - model_metadata: Mapping[str, Any] | None = None, - force: bool = False, - trigger: str = "auto", - keep_tail_groups: Optional[int] = None, - session_service_provider: Callable[[], Any] | None = None, -) -> SessionEvent | None: - """把旧轮次折叠为 checkpoint。 - - 这是本地版的 compaction:先按 API round 分组,再保留尾部若干轮,把更早 - 的部分压成 append-only summary 事件。force=True 时用于 PTL 恢复。 - """ - provider = session_service_provider or resolve_session_service - service = provider() - events = await service.get_events(session_id) - plan = _plan_compaction( - events, - model=model, - model_metadata=model_metadata, - force=force, - keep_tail_groups=keep_tail_groups, - ) - if not plan.should_compact: - return None - - previous_summary = "" - latest_checkpoint = next( - ( - event - for event in reversed(events) - if canonical_event_type(event.event_type) == "context_checkpoint" - ), - None, - ) - if latest_checkpoint: - previous_summary = extract_event_text(latest_checkpoint) - - compacted_until_seq_id_value = int(plan.compacted_until_seq_id or 0) - resolved_model_metadata = _resolve_model_metadata(model, model_metadata=model_metadata) - threshold_tokens = get_auto_compact_threshold_tokens(resolved_model_metadata) - - # P0.5 分层 compaction pipeline:L2 Snip + L3 Microcompact(零 LLM 成本确定性裁剪), - # 只作用于送 summarizer 的 candidate 投影,绝不删 append-only transcript。 - # 详见 ksadk/conversations/compaction_pipeline.py。 - pipeline_result = run_pipeline( - plan.groups_to_compact, - threshold_tokens=threshold_tokens, - pinned_state=plan.pinned_state, - previous_summary=previous_summary, - ) - candidate_groups = pipeline_result["candidate_groups"] - - summary_result = await summarize_compaction( - groups_to_compact=candidate_groups, - previous_summary=previous_summary, - pinned_state=plan.pinned_state, - model_metadata=resolved_model_metadata, - model=model, - ) - - # L5 working set 恢复(保守版):只记 metadata,不读文件内容。 - working_set = build_working_set_metadata(pinned_state=plan.pinned_state) - - return await append_context_checkpoint_event( - session_id=session_id, - author=author, - compacted_until_seq_id=compacted_until_seq_id_value, - summary_text=summary_result.summary_text, - trigger=trigger, - invocation_id=invocation_id, - metadata={ - "head_seq_id": plan.groups_to_compact[0][0].seq_id, - "tail_seq_id": plan.groups_to_compact[-1][-1].seq_id, - "invocation_ids": [ - event.invocation_id - for group in plan.groups_to_compact - for event in group - if event.invocation_id - ], - "summary_strategy": summary_result.summary_strategy, - "summary_version": summary_result.summary_version, - "summary_model": summary_result.summary_model, - "summary_usage": summary_result.summary_usage, - "fallback_reason": summary_result.fallback_reason, - # P0.5 pipeline 审计字段(原始 transcript 未改,这里只记投影统计)。 - "pipeline_stages": pipeline_result["pipeline_stages"], - "tokens_before": pipeline_result["tokens_before"], - "tokens_after": pipeline_result["tokens_after"], - "snip_stats": { - "removed_redundant_tool_results": pipeline_result[ - "snip_stats" - ].removed_redundant_tool_results, - "snip_released_tokens": pipeline_result["snip_released_tokens"], - "covered_seq_range": list(pipeline_result["snip_stats"].covered_seq_range or []), - }, - "microcompact_stats": ( - { - "compacted_groups": pipeline_result["microcompact_stats"].compacted_groups, - "tokens_before": pipeline_result["microcompact_stats"].tokens_before, - "tokens_after": pipeline_result["microcompact_stats"].tokens_after, - "preserved_receipts": pipeline_result["microcompact_stats"].preserved_receipts, - } - if pipeline_result["microcompact_stats"] is not None - else None - ), - "working_set": working_set, - }, - session_service_provider=provider, - ) - - -async def build_run_input( - *, - agent_id: str, - user_id: str, - session_id: Optional[str], - messages: Sequence[Dict[str, Any]], - model: Optional[str] = None, - 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, - resume_input: Mapping[str, Any] | None = None, - invocation_id: Optional[str] = None, - governance_state: RuntimeGovernanceState | None = None, - session_service_provider: Callable[[], Any] | None = None, - run_mode: str = RUN_MODE_FOREGROUND, -) -> PreparedConversationTurn: - """构建一次 turn 的标准运行输入,并在进入模型前做上下文投影/压缩。 - - run_mode 由 caller 按 endpoint 语义传入(Background:true→background,普通→foreground); - run_trigger 由 resume_input 推导(new_run/checkpoint_resume/approval_resume)。 - """ - caller_run_mode = validate_run_mode(run_mode) - caller_run_trigger = trigger_from_resume_input(resume_input) - provider = session_service_provider or resolve_session_service - service = provider() - resolved_user_id = user_id - if session_id: - existing_session = await service.get_session(session_id) - if existing_session and existing_session.user_id: - resolved_user_id = existing_session.user_id - - session = await ensure_conversation_session( - agent_id=agent_id, - user_id=resolved_user_id, - session_id=session_id, - session_service_provider=provider, - ) - resolved_session_id = session.id - resolved_invocation_id = str(invocation_id or uuid.uuid4()) - resolved_model_metadata = await _resolve_runtime_model_metadata( - model, - model_metadata=model_metadata, - ) - normalized_request_metadata = dict(request_metadata or {}) - policy_model = model or os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") - normalized_model_options = { - **normalize_model_options(model_options), - **model_policy_options_for_model(policy_model), - } - normalized_instructions = str(instructions or "").strip() - - if resume_input is not None: - if not session_id: - raise ValueError("Responses resume input requires session_id") - existing_events = await service.get_events(resolved_session_id) - normalized_resume_input = dict(resume_input) - if _is_checkpoint_resume_input(normalized_resume_input): - normalized_resume_input = _normalize_checkpoint_resume_input(normalized_resume_input) - await append_run_resume_event( - session_id=resolved_session_id, - author=agent_id, - run_id=str(normalized_resume_input["run_id"]), - checkpoint_id=str(normalized_resume_input["checkpoint_id"]), - resume_attempt_id=str(normalized_resume_input["resume_attempt_id"]), - framework=str(normalized_resume_input["framework"]), - framework_ref=normalized_resume_input["framework_ref"], - invocation_id=resolved_invocation_id, - session_service_provider=provider, - ) - # 补写 run_status(resuming):让 ActiveRunStatus 在 resume 期间正确反映"恢复中"。 - # append_run_resume_event 写的是 run_resume 事件(status=resuming),而 - # _latest_session_run_status 只扫 run_status 事件 → 不补写则 - # resuming 不进 ActiveRunStatus。 - await append_run_status_event( - session_id=resolved_session_id, - author=agent_id, - status="resuming", - invocation_id=resolved_invocation_id, - detail="checkpoint_resume", - session_service_provider=provider, - run_mode=caller_run_mode, - run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, - ) - history = build_history_from_events(await service.get_events(resolved_session_id)) - return PreparedConversationTurn( - session_id=resolved_session_id, - invocation_id=resolved_invocation_id, - user_input="", - user_display_input="", - history=history, - input_content=[], - input_messages=[], - user_parts=[], - attachments=[], - attachment_results=[], - current_attachments=[], - current_attachment_results=[], - has_current_files=False, - model_metadata=resolved_model_metadata, - model_options=normalized_model_options, - instructions=normalized_instructions, - request_metadata={ - **normalized_request_metadata, - **_agentengine_resume_metadata(normalized_resume_input), - }, - resume_input=normalized_resume_input, - run_mode=caller_run_mode, - run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, - ) - - is_approval_resume = _is_approval_resume_input(normalized_resume_input) - existing_tool_receipt_event = None - if is_approval_resume and not _has_pending_approval(existing_events): - replay_candidate = _normalize_approval_resume_input( - normalized_resume_input, - existing_events, - include_resolved=True, - ) - receipt_key = _tool_receipt_idempotency_key_for_resume( - session_id=resolved_session_id, - resume_input=replay_candidate, - ) - if receipt_key: - existing_tool_receipt_event = _find_tool_receipt_event_by_key( - existing_events, - receipt_key, - ) - if existing_tool_receipt_event is not None: - normalized_resume_input = replay_candidate - else: - raise ValueError("Responses resume input requires a pending approval_request") - if is_approval_resume: - normalized_resume_input = _normalize_approval_resume_input( - normalized_resume_input, - existing_events, - ) - caller_run_mode = _approval_resume_run_mode( - existing_events, - normalized_resume_input, - fallback=caller_run_mode, - ) - if governance_state is not None: - governance_state.consecutive_approval_denials = ( - _consecutive_approval_denials_from_events(existing_events) - ) - - resume_text = _format_resume_response_text(normalized_resume_input) - await append_conversation_event( - session_id=resolved_session_id, - author="user", - role="user", - text=resume_text, - invocation_id=resolved_invocation_id, - event_type="approval_response" if is_approval_resume else "tool_result", - session_service_provider=provider, - metadata={"resume_input": normalized_resume_input}, - ) - if is_approval_resume and governance_state is not None: - decision = normalized_resume_input.get("approval") - if not isinstance(decision, Mapping): - decision = _approval_decision_from_resume(normalized_resume_input) - _governance_record_approval_response(governance_state, decision) - tool_resume_input = None - if is_approval_resume: - tool_resume_input = await _execute_approved_builtin_tool_resume( - session_id=resolved_session_id, - invocation_id=resolved_invocation_id, - resume_input=normalized_resume_input, - session_service_provider=provider, - ) - effective_resume_input = tool_resume_input or normalized_resume_input - history = build_history_from_events(await service.get_events(resolved_session_id)) - return PreparedConversationTurn( - session_id=resolved_session_id, - invocation_id=resolved_invocation_id, - user_input=resume_text, - user_display_input=resume_text, - history=history, - input_content=[], - input_messages=[], - user_parts=[], - attachments=[], - attachment_results=[], - current_attachments=[], - current_attachment_results=[], - has_current_files=False, - model_metadata=resolved_model_metadata, - model_options=normalized_model_options, - instructions=normalized_instructions, - request_metadata=normalized_request_metadata, - resume_input=effective_resume_input, - run_mode=caller_run_mode, - run_trigger=RUN_TRIGGER_APPROVAL_RESUME, - ) - - normalized_messages = _normalized_conversation_messages(messages) - ( - user_input, - user_display_input, - input_content, - user_parts, - attachments, - attachment_results, - ) = _latest_user_turn(normalized_messages) - input_messages = _canonical_input_messages(normalized_messages) - effective_attachments, effective_attachment_results = _resolve_effective_attachment_context( - normalized_messages=normalized_messages, - session=session, - ) - effective_state_delta = _build_attachment_context_state_delta( - base_state_delta=state_delta, - attachments=attachments, - attachment_results=attachment_results, - ) - event_metadata = { - "agent_input": user_input, - "attachments": [compact_attachment_for_session(item) for item in attachments if item], - "attachment_results": [ - compact_attachment_result_for_session(item) for item in attachment_results if item - ], - } - - if normalized_instructions: - event_metadata["instructions"] = normalized_instructions - deferred_tool_names = _latest_deferred_tool_names(await service.get_events(resolved_session_id)) - if deferred_tool_names and "deferred_tool_names" not in normalized_request_metadata: - normalized_request_metadata["deferred_tool_names"] = deferred_tool_names - if normalized_request_metadata: - event_metadata["request_metadata"] = normalized_request_metadata - - await append_conversation_event( - session_id=resolved_session_id, - author="user", - role="user", - text=user_display_input or user_input, - invocation_id=resolved_invocation_id, - event_type="user_message", - state_delta=effective_state_delta, - content=_user_event_content( - user_input=user_input, - user_display_input=user_display_input, - input_content=input_content, - user_parts=user_parts, - ), - session_service_provider=provider, - metadata=event_metadata, - ) - await _update_session_metadata_after_user_turn( - service=service, - session=session, - user_input=user_input or user_display_input, - ) - - checkpoint = await _compact_conversation_history_with_governance( - governance_state, - session_id=resolved_session_id, - author=agent_id, - invocation_id=resolved_invocation_id, - model=model, - model_metadata=resolved_model_metadata, - session_service_provider=provider, - ) - history = build_history_from_events(await service.get_events(resolved_session_id)) - request_history = build_request_history(normalized_messages[:-1]) - # Gateway / Responses callers may send full prompt context while the - # runtime-local session is empty or stale (for example after pod - # replacement). Preserve that request context, but do not duplicate it when - # local session events already contain the same prefix. - history = _merge_request_history_with_session_history(request_history, history) - - return PreparedConversationTurn( - session_id=resolved_session_id, - invocation_id=resolved_invocation_id, - user_input=user_input, - user_display_input=user_display_input or user_input, - history=history, - input_content=input_content, - input_messages=input_messages, - user_parts=user_parts, - attachments=effective_attachments, - attachment_results=effective_attachment_results, - current_attachments=attachments, - current_attachment_results=attachment_results, - has_current_files=bool(attachments or _parts_include_file(user_parts)), - model_metadata=resolved_model_metadata, - model_options=normalized_model_options, - instructions=normalized_instructions, - request_metadata=normalized_request_metadata, - compaction_triggered=checkpoint is not None, - compaction_trigger=( - str((checkpoint.metadata or {}).get("trigger") or "auto") if checkpoint else None - ), - compacted_until_seq_id=( - int((checkpoint.metadata or {}).get("compacted_until_seq_id") or 0) - if checkpoint - else None - ), - run_mode=caller_run_mode, - run_trigger=caller_run_trigger, - ) - - -async def _refresh_history( - prepared: PreparedConversationTurn, *, session_service_provider: Callable[[], Any] | None = None -) -> PreparedConversationTurn: - """在 compaction 后刷新 prepared turn 的 history 视图。""" - provider = session_service_provider or resolve_session_service - service = provider() - prepared.history = build_history_from_events(await service.get_events(prepared.session_id)) - return prepared - - -async def invoke_conversation_once( - *, - runner: Any, - agent_id: str, - user_id: str, - session_id: Optional[str], - messages: Sequence[Dict[str, Any]], - model: Optional[str], - prepare_runner: Callable[[Any, Optional[str]], None], - 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, - resume_input: Mapping[str, Any] | None = None, - response_id: str | 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, -) -> tuple[str, dict[str, Any]]: - """非流式 turn 编排入口。 - - 顺序固定为:写用户事件 -> 需要时 compact -> 写 run_status(in_progress) - -> 调 runner -> PTL 时 compact/retry -> 写 assistant 结果 -> 写 completed。 - """ - provider = session_service_provider or resolve_session_service - prepare_runner(runner, model) - governance = _runtime_governance_from_env() - _governance_record_turn_start(governance) - # 入口算 run_trigger(不依赖 prepared,build_run_input 失败时也能用) - entry_run_mode = validate_run_mode(run_mode) - entry_run_trigger = trigger_from_resume_input(resume_input) - try: - 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, - resume_input=resume_input, - invocation_id=invocation_id, - governance_state=governance, - session_service_provider=provider, - run_mode=entry_run_mode, - ) - # prepared 之后的 run_status 写入复用 prepared 的 mode/trigger - run_mode = prepared.run_mode - run_trigger = prepared.run_trigger - except RuntimeCircuitOpen as exc: - if session_id: - await append_run_status_event( - session_id=session_id, - author=_runner_name(runner), - status=_failed_status_for_resume(resume_input), - invocation_id=invocation_id, - detail=str(exc), - metadata={"governance": exc.metadata}, - session_service_provider=provider, - run_mode=entry_run_mode, - run_trigger=entry_run_trigger, - ) - raise - _inject_runner_deferred_tools_for_request(runner, prepared) - ambient_contexts = _build_runner_ambient_contexts( - runner=runner, - user_id=user_id, - user_input=prepared.user_input, - ) - runtime_context = PlatformInvocationContext( - agent_id=agent_id, - user_id=user_id, - account_id=str(account_id or ""), - session_id=prepared.session_id, - history=list(prepared.history), - input_content=list(prepared.input_content), - input_messages=list(prepared.input_messages), - input_parts=list(prepared.user_parts), - attachments=list(prepared.attachments), - attachment_results=list(prepared.attachment_results), - current_attachments=list(prepared.current_attachments), - current_attachment_results=list(prepared.current_attachment_results), - has_current_files=prepared.has_current_files, - runner_type=_runner_type_name(runner), - model=model, - model_options=prepared.model_options, - kb_context=ambient_contexts.get("kb_context"), - memory_context=ambient_contexts.get("memory_context"), - ) - runner_name = _runner_name(runner) - async with _conversation_span_scope(runner_name) as span: - _set_conversation_span_attributes( - span, - agent_id=agent_id, - user_id=user_id, - session_id=prepared.session_id, - invocation_id=prepared.invocation_id, - runner_name=runner_name, - model=model, - response_id=response_id, - ) - _set_conversation_input_attributes(span, prepared.user_input or prepared.user_display_input) - trace_metadata = _span_feedback_metadata(span) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="in_progress", - invocation_id=prepared.invocation_id, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - - result: dict[str, Any] | None = None - last_invoke_error: Exception | None = None - for attempt in range(2): - try: - last_invoke_error = None - runtime_context.history = list(prepared.history) - with platform_invocation_scope(runtime_context): - with tool_execution_scope( - session_id=prepared.session_id, - run_id=prepared.invocation_id, - invocation_id=prepared.invocation_id, - ): - result = await runner.invoke( - _build_runner_request_payload( - prepared=prepared, - model=model, - runtime_context=runtime_context, - ) - ) - break - except asyncio.CancelledError: - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="cancelled", - invocation_id=prepared.invocation_id, - detail="cancel_requested", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - raise - except Exception as exc: - if attempt == 0 and _is_prompt_too_long_error(exc): - try: - checkpoint = await _compact_conversation_history_with_governance( - governance, - session_id=prepared.session_id, - author=runner_name, - invocation_id=prepared.invocation_id, - model=model, - model_metadata=prepared.model_metadata, - force=True, - trigger="prompt_too_long", - keep_tail_groups=PTL_RETRY_KEEP_TAIL_GROUPS, - session_service_provider=provider, - ) - except RuntimeCircuitOpen as circuit_exc: - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status=_failed_status_for_resume(resume_input), - invocation_id=prepared.invocation_id, - detail=str(circuit_exc), - metadata={"governance": circuit_exc.metadata}, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - raise circuit_exc - if checkpoint: - prepared = await _refresh_history( - prepared, session_service_provider=provider - ) - runtime_context.history = list(prepared.history) - continue - fallback_model = fallback_model_for_exception(exc, current_model=model) - if attempt == 0 and fallback_model: - model = fallback_model - runtime_context.model = fallback_model - runtime_context.model_options = { - **prepared.model_options, - **model_policy_options_for_model(fallback_model), - } - prepare_runner(runner, fallback_model) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="in_progress", - invocation_id=prepared.invocation_id, - detail=f"fallback_model:{fallback_model}", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - continue - last_invoke_error = exc - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status=_failed_status_for_resume(resume_input), - invocation_id=prepared.invocation_id, - detail=str(exc), - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - break - - if last_invoke_error is not None: - raise last_invoke_error - result = result or {} - output_text = strip_reasoning_markup(str(result.get("output", ""))) - result_usage = _normalize_usage_payload(result.get("usage")) - result_last_usage = _normalize_usage_payload( - (result.get("metadata") or {}).get("last_usage") - ) or (result_usage if result_usage else {}) - _set_conversation_output_attributes(span, output_text) - _set_conversation_usage_attributes(span, result_usage) - result_agentengine_metadata = _extract_agentengine_metadata(result) - assistant_metadata: dict[str, Any] = { - **trace_metadata, - **_merge_agentengine_metadata(prepared.request_metadata, result_agentengine_metadata), - } - if prepared.request_metadata: - request_metadata_without_agentengine = { - key: value - for key, value in prepared.request_metadata.items() - if key != "agentengine" - } - if request_metadata_without_agentengine: - assistant_metadata["request_metadata"] = request_metadata_without_agentengine - if result_usage: - assistant_metadata["usage"] = result_usage - if result_last_usage: - assistant_metadata["last_usage"] = result_last_usage - if response_id: - assistant_metadata["response_id"] = response_id - checkpoint_args = _checkpoint_event_args_from_agentengine_metadata( - assistant_metadata.get("agentengine") - if isinstance(assistant_metadata, Mapping) - else None, - fallback_run_id=prepared.invocation_id, - ) - if checkpoint_args: - await append_run_checkpoint_event( - session_id=prepared.session_id, - author=runner_name, - run_id=checkpoint_args["run_id"], - checkpoint_id=checkpoint_args["checkpoint_id"], - framework=checkpoint_args["framework"], - framework_ref=checkpoint_args["framework_ref"], - phase=checkpoint_args.get("phase") or "completed", - invocation_id=prepared.invocation_id, - metadata=checkpoint_args.get("metadata"), - session_service_provider=provider, - ) - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="model", - text=output_text, - invocation_id=prepared.invocation_id, - event_type="assistant_message", - metadata=assistant_metadata or None, - session_service_provider=provider, - ) - await _update_session_metadata_after_assistant_turn( - service=provider(), - session_id=prepared.session_id, - assistant_text=output_text, - model=model, - ) - await _auto_save_ltm_turn( - agent_id=agent_id, - user_id=user_id, - prepared=prepared, - output_text=output_text, - runner_type=runtime_context.runner_type, - model=model, - ) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="completed", - invocation_id=prepared.invocation_id, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - result_payload = { - "output_text": output_text, - "model": model, - "metadata": { - **trace_metadata, - **{ - key: value - for key, value in prepared.request_metadata.items() - if key != "agentengine" - }, - **_merge_agentengine_metadata( - prepared.request_metadata, result_agentengine_metadata - ), - }, - } - if result_usage: - result_payload["usage"] = result_usage - result_payload["metadata"]["usage"] = result_usage - if result_last_usage: - result_payload["metadata"]["last_usage"] = result_last_usage - if response_id: - result_payload["response_id"] = response_id - return prepared.session_id, result_payload - - -def _response_sse(event: str, data: Mapping[str, Any]) -> str: - return f"event: {event}\ndata: {json.dumps(dict(data), ensure_ascii=False)}\n\n" - - -async def _iter_conversation_turn_events( - *, - runner: Any, - agent_id: str, - user_id: str, - session_id: Optional[str], - messages: Sequence[Dict[str, Any]], - model: Optional[str], - prepare_runner: Callable[[Any, Optional[str]], None], - 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, - resume_input: Mapping[str, Any] | None = None, - response_id: str | 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, -) -> AsyncIterator[dict[str, Any]]: - """Internal semantic event stream shared by protocol serializers.""" - provider = session_service_provider or resolve_session_service - prepare_runner(runner, model) - governance = _runtime_governance_from_env() - _governance_record_turn_start(governance) - entry_run_mode = validate_run_mode(run_mode) - entry_run_trigger = trigger_from_resume_input(resume_input) - if resume_input is None: - 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, - ) - else: - compaction_preview = CompactionPlan( - should_compact=False, - groups_to_compact=[], - total_chars=0, - total_estimated_tokens=0, - group_count=0, - tail_groups=0, - ) - if compaction_preview.should_compact: - yield { - "type": "compaction", - "phase": "start", - "trigger": "auto", - "total_chars": compaction_preview.total_chars, - "total_estimated_tokens": compaction_preview.total_estimated_tokens, - "group_count": compaction_preview.group_count, - "threshold_percentage": compaction_preview.auto_compact_threshold_percentage, - } - try: - 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, - resume_input=resume_input, - invocation_id=invocation_id, - governance_state=governance, - session_service_provider=provider, - run_mode=entry_run_mode, - ) - # prepared 之后的 run_status 写入复用 prepared 的 mode/trigger - run_mode = prepared.run_mode - run_trigger = prepared.run_trigger - except RuntimeCircuitOpen as exc: - if session_id: - await append_run_status_event( - session_id=session_id, - author=_runner_name(runner), - status=_failed_status_for_resume(resume_input), - invocation_id=invocation_id, - detail=str(exc), - metadata={"governance": exc.metadata}, - session_service_provider=provider, - run_mode=entry_run_mode, - run_trigger=entry_run_trigger, - ) - yield {"type": "error", "message": str(exc) or "Agent 运行失败"} - return - _inject_runner_deferred_tools_for_request(runner, prepared) - ambient_contexts = _build_runner_ambient_contexts( - runner=runner, - user_id=user_id, - user_input=prepared.user_input, - ) - runtime_context = PlatformInvocationContext( - agent_id=agent_id, - user_id=user_id, - account_id=str(account_id or ""), - session_id=prepared.session_id, - history=list(prepared.history), - input_content=list(prepared.input_content), - input_messages=list(prepared.input_messages), - input_parts=list(prepared.user_parts), - attachments=list(prepared.attachments), - attachment_results=list(prepared.attachment_results), - current_attachments=list(prepared.current_attachments), - current_attachment_results=list(prepared.current_attachment_results), - has_current_files=prepared.has_current_files, - runner_type=_runner_type_name(runner), - model=model, - model_options=prepared.model_options, - kb_context=ambient_contexts.get("kb_context"), - memory_context=ambient_contexts.get("memory_context"), - ) - if prepared.compaction_triggered: - yield { - "type": "compaction", - "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 - ), - "total_estimated_tokens": ( - compaction_preview.total_estimated_tokens - if compaction_preview.should_compact - else None - ), - "group_count": ( - compaction_preview.group_count if compaction_preview.should_compact else None - ), - "threshold_percentage": ( - compaction_preview.auto_compact_threshold_percentage - if compaction_preview.should_compact - else None - ), - } - runner_name = _runner_name(runner) - tracer = _get_conversation_tracer() - span = tracer.start_span(runner_name) if tracer else None - span_ended = False - - def _finish_span() -> None: - nonlocal span_ended - if span is None or span_ended: - return - span_ended = True - try: - span.end() - except Exception: - pass - - try: - _set_conversation_span_attributes( - span, - agent_id=agent_id, - user_id=user_id, - session_id=prepared.session_id, - invocation_id=prepared.invocation_id, - runner_name=runner_name, - model=model, - response_id=response_id, - ) - _set_conversation_input_attributes(span, prepared.user_input or prepared.user_display_input) - trace_metadata = _span_feedback_metadata(span) - yield { - "type": "started", - "session_id": prepared.session_id, - "metadata": {**trace_metadata, **dict(request_metadata or {})}, - } - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="in_progress", - invocation_id=prepared.invocation_id, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - - accumulated_text = "" - accumulated_reasoning_parts: list[str] = [] - emitted_anything = False - emitted_response_artifacts = False - saw_final_chunk = False - responses_output: list[Any] = [] - responses_response_id: str | None = response_id - runner_agentengine_metadata: dict[str, Any] = {} - stream_usage: dict[str, Any] = {} - stream_last_usage: dict[str, Any] = {} - reasoning_disabled = _model_options_disable_reasoning(prepared.model_options) - - async def _persist_accumulated_reasoning() -> None: - if not accumulated_reasoning_parts: - return - reasoning = "".join(accumulated_reasoning_parts) - accumulated_reasoning_parts.clear() - await append_reasoning_event( - session_id=prepared.session_id, - author=runner_name, - text=reasoning, - invocation_id=prepared.invocation_id, - session_service_provider=provider, - ) - - for attempt in range(2): - try: - runtime_context.history = list(prepared.history) - with platform_invocation_scope(runtime_context): - with tool_execution_scope( - session_id=prepared.session_id, - run_id=prepared.invocation_id, - invocation_id=prepared.invocation_id, - ): - stream = runner.stream( - _build_runner_request_payload( - prepared=prepared, - model=model, - runtime_context=runtime_context, - ) - ) - while True: - try: - with _span_current_context(span): - chunk = await anext(stream) - except StopAsyncIteration: - break - chunk_type = chunk.get("type") - if chunk_type == "checkpoint": - emitted_anything = True - chunk_agentengine_metadata = _extract_agentengine_metadata(chunk) - if chunk_agentengine_metadata: - runner_agentengine_metadata.update(chunk_agentengine_metadata) - resume_run_id = "" - if prepared.resume_input and _is_checkpoint_resume_input( - prepared.resume_input - ): - resume_run_id = str( - prepared.resume_input.get("run_id") or "" - ).strip() - checkpoint_args = ( - _checkpoint_event_args_from_agentengine_metadata( - runner_agentengine_metadata.get("agentengine"), - fallback_run_id=resume_run_id or prepared.invocation_id, - ) - ) - if checkpoint_args: - await append_run_checkpoint_event( - session_id=prepared.session_id, - author=runner_name, - run_id=checkpoint_args["run_id"], - checkpoint_id=checkpoint_args["checkpoint_id"], - framework=checkpoint_args["framework"], - framework_ref=checkpoint_args["framework_ref"], - phase=checkpoint_args.get("phase") or "stream", - invocation_id=prepared.invocation_id, - metadata=checkpoint_args.get("metadata"), - session_service_provider=provider, - ) - continue - if chunk_type == "responses_output": - if isinstance(chunk.get("usage"), Mapping): - stream_usage = _normalize_usage_payload(chunk.get("usage")) - chunk_last = (chunk.get("metadata") or {}).get("last_usage") - if isinstance(chunk_last, Mapping): - stream_last_usage = _normalize_usage_payload(chunk_last) or stream_usage - raw_output = chunk.get("output") - responses_output = ( - raw_output if isinstance(raw_output, list) else [] - ) - if reasoning_disabled: - responses_output = _filter_responses_reasoning_output( - responses_output - ) - raw_response_id = chunk.get("response_id") - responses_response_id = ( - str(raw_response_id) - if raw_response_id - else responses_response_id - ) - if responses_output and not emitted_response_artifacts: - for semantic_event in _semantic_events_from_responses_output( - responses_output - ): - if semantic_event.get("type") == "thinking": - accumulated_reasoning_parts.append( - str(semantic_event.get("delta") or "") - ) - emitted_anything = True - yield semantic_event - continue - if chunk_type == "thinking": - if reasoning_disabled: - continue - delta = str(chunk.get("delta", "")) - if delta: - accumulated_reasoning_parts.append(delta) - emitted_anything = True - emitted_response_artifacts = True - yield {"type": "thinking", "delta": delta} - continue - if chunk_type == "text": - delta = str(chunk.get("delta", "")) - if delta: - accumulated_text += delta - emitted_anything = True - yield {"type": "text", "delta": delta} - continue - if chunk_type == "tool_call": - _governance_record_tool_call(governance) - emitted_response_artifacts = True - tool_args = chunk.get("tool_args", {}) - if not isinstance(tool_args, Mapping): - tool_args = {} - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="model", - text=str(chunk.get("tool_name") or "tool"), - invocation_id=prepared.invocation_id, - event_type="tool_call", - metadata={ - "tool_name": chunk.get("tool_name"), - "tool_args": dict(tool_args), - "run_id": chunk.get("run_id"), - "stage": chunk.get("stage") or tool_args.get("stage"), - "event_kind": chunk.get("event_kind"), - "display_title": chunk.get("display_title"), - "display_summary": chunk.get("display_summary"), - }, - session_service_provider=provider, - ) - emitted_anything = True - await _persist_accumulated_reasoning() - yield { - "type": "tool_call", - "name": chunk.get("tool_name"), - "args": dict(tool_args), - "run_id": chunk.get("run_id"), - "stage": chunk.get("stage") or tool_args.get("stage"), - "event_kind": chunk.get("event_kind"), - "display_title": chunk.get("display_title"), - "display_summary": chunk.get("display_summary"), - } - continue - if chunk_type in {"stage_tool_call", "stage_tool_result"}: - emitted_response_artifacts = True - tool_name = str( - chunk.get("tool_name") or chunk.get("name") or "tool" - ) - tool_args = chunk.get("tool_args", chunk.get("args", {})) - if not isinstance(tool_args, Mapping): - tool_args = {} - tool_output = chunk.get("tool_output", chunk.get("output", "")) - event_kind = str(chunk.get("event_kind") or "") - display_title = str(chunk.get("display_title") or tool_name) - display_summary = str( - chunk.get("display_summary") or chunk.get("text") or "" - ) - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="model" if chunk_type == "stage_tool_call" else "user", - text=display_summary or tool_name, - invocation_id=prepared.invocation_id, - event_type=chunk_type, - metadata={ - "tool_name": tool_name, - "tool_args": dict(tool_args), - "tool_output": tool_output, - "run_id": chunk.get("run_id"), - "stage": chunk.get("stage") or tool_args.get("stage"), - "event_kind": event_kind, - "display_title": display_title, - "display_summary": display_summary, - }, - session_service_provider=provider, - ) - emitted_anything = True - yield { - "type": chunk_type, - "name": tool_name, - "args": dict(tool_args), - "output": tool_output, - "run_id": chunk.get("run_id"), - "stage": chunk.get("stage") or tool_args.get("stage"), - "event_kind": event_kind, - "display_title": display_title, - "display_summary": display_summary, - } - continue - if chunk_type == "tool_result": - emitted_response_artifacts = True - tool_name = str(chunk.get("tool_name") or "tool") - tool_args = chunk.get("tool_args", {}) - if not isinstance(tool_args, Mapping): - tool_args = {} - tool_run_id = str(chunk.get("run_id") or prepared.invocation_id) - checkpoint_metadata = _latest_checkpoint_metadata_for_run( - await provider().get_events(prepared.session_id), - tool_run_id, - ) - approval_interrupt_info = approval_interrupt_info_from_result( - chunk.get("tool_output", ""), - fallback_tool_name=tool_name, - tool_args=tool_args, - run_id=tool_run_id, - ) - if approval_interrupt_info: - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="model", - text="approval requested", - invocation_id=prepared.invocation_id, - event_type="approval_request", - metadata={"interrupt_info": approval_interrupt_info}, - session_service_provider=provider, - ) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="interrupted", - invocation_id=prepared.invocation_id, - detail="approval_required", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - emitted_anything = True - await _persist_accumulated_reasoning() - yield { - "type": "interrupt", - "interrupt_info": approval_interrupt_info, - "session_id": prepared.session_id, - "metadata": {**trace_metadata, **prepared.request_metadata}, - } - return - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="user", - text=str(chunk.get("tool_output", "")), - invocation_id=prepared.invocation_id, - event_type="tool_result", - metadata={ - "tool_name": tool_name, - "tool_output": chunk.get("tool_output", ""), - "run_id": tool_run_id, - "observability": _tool_observability_metadata( - tool_name, chunk.get("tool_output", "") - ), - "tool_receipt": _tool_receipt_metadata( - session_id=prepared.session_id, - run_id=tool_run_id, - tool_call_id=tool_run_id, - tool_name=tool_name, - tool_args=tool_args, - checkpoint_id=checkpoint_metadata.get("checkpoint_id"), - framework=checkpoint_metadata.get("framework"), - framework_ref=checkpoint_metadata.get("framework_ref"), - status=( - "failed" - if isinstance(chunk.get("tool_output"), Mapping) - and chunk.get("tool_output", {}).get("ok") is False - else "completed" - ), - ), - }, - session_service_provider=provider, - ) - deferred_tool_names = _extract_deferred_tool_names( - chunk.get("tool_output", "") - ) - if tool_name == "tool_search" and deferred_tool_names: - await append_deferred_tools_event( - session_id=prepared.session_id, - author=runner_name, - deferred_tool_names=deferred_tool_names, - invocation_id=prepared.invocation_id, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - _governance_record_tool_result( - governance, chunk.get("tool_output", "") - ) - emitted_anything = True - await _persist_accumulated_reasoning() - yield { - "type": "tool_result", - "name": chunk.get("tool_name"), - "output": chunk.get("tool_output", ""), - "run_id": chunk.get("run_id"), - } - continue - if chunk_type == "interrupt": - interrupt_info = chunk.get("interrupt_info") - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="model", - text="approval requested", - invocation_id=prepared.invocation_id, - event_type="approval_request", - metadata={"interrupt_info": interrupt_info}, - session_service_provider=provider, - ) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="interrupted", - invocation_id=prepared.invocation_id, - detail="approval_required", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - emitted_anything = True - yield { - "type": "interrupt", - "interrupt_info": interrupt_info, - "session_id": prepared.session_id, - "metadata": {**trace_metadata, **prepared.request_metadata}, - } - return - if chunk_type == "final": - saw_final_chunk = True - final_text = str(chunk.get("output", "")) - if final_text: - accumulated_text = final_text - if isinstance(chunk.get("usage"), Mapping): - stream_usage = _normalize_usage_payload(chunk.get("usage")) - chunk_last = (chunk.get("metadata") or {}).get("last_usage") - if isinstance(chunk_last, Mapping): - stream_last_usage = _normalize_usage_payload(chunk_last) or stream_usage - break - except asyncio.CancelledError: - await _persist_accumulated_reasoning() - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="cancelled", - invocation_id=prepared.invocation_id, - detail="cancel_requested", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - yield { - "type": "cancelled", - "session_id": prepared.session_id, - "metadata": {**trace_metadata, **prepared.request_metadata}, - } - return - except Exception as exc: - if attempt == 0 and not emitted_anything and _is_prompt_too_long_error(exc): - yield {"type": "compaction", "phase": "start", "trigger": "prompt_too_long"} - try: - checkpoint = await _compact_conversation_history_with_governance( - governance, - session_id=prepared.session_id, - author=runner_name, - invocation_id=prepared.invocation_id, - model=model, - model_metadata=prepared.model_metadata, - force=True, - trigger="prompt_too_long", - keep_tail_groups=PTL_RETRY_KEEP_TAIL_GROUPS, - session_service_provider=provider, - ) - except RuntimeCircuitOpen as circuit_exc: - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status=_failed_status_for_resume(resume_input), - invocation_id=prepared.invocation_id, - detail=str(circuit_exc), - metadata={"governance": circuit_exc.metadata}, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - await _persist_accumulated_reasoning() - yield {"type": "error", "message": str(circuit_exc) or "Agent 运行失败"} - return - if checkpoint: - yield { - "type": "compaction", - "phase": "done", - "trigger": "prompt_too_long", - "compacted_until_seq_id": int( - (checkpoint.metadata or {}).get("compacted_until_seq_id") or 0 - ) - or None, - } - prepared = await _refresh_history( - prepared, session_service_provider=provider - ) - runtime_context.history = list(prepared.history) - continue - if attempt == 0 and not emitted_anything: - fallback_model = fallback_model_for_exception(exc, current_model=model) - if fallback_model: - model = fallback_model - prepare_runner(runner, fallback_model) - prepared.model_options = { - **prepared.model_options, - **model_policy_options_for_model(fallback_model), - } - runtime_context.model = fallback_model - runtime_context.model_options = prepared.model_options - _set_span_attribute(span, "ksadk.model.fallback", fallback_model) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="in_progress", - invocation_id=prepared.invocation_id, - detail=f"fallback_model:{fallback_model}", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - continue - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status=_failed_status_for_resume(resume_input), - invocation_id=prepared.invocation_id, - detail=str(exc), - metadata={"governance": exc.metadata} - if isinstance(exc, RuntimeCircuitOpen) - else None, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - await _persist_accumulated_reasoning() - yield {"type": "error", "message": str(exc) or "Agent 运行失败"} - return - - request_metadata_without_agentengine = { - key: value - for key, value in dict(request_metadata or {}).items() - if key != "agentengine" - } - assistant_metadata = { - **trace_metadata, - **request_metadata_without_agentengine, - **_merge_agentengine_metadata(request_metadata, runner_agentengine_metadata), - } - if responses_output: - assistant_metadata["responses_output"] = responses_output - if stream_usage: - assistant_metadata["usage"] = stream_usage - if stream_last_usage: - assistant_metadata["last_usage"] = stream_last_usage - elif stream_usage: - assistant_metadata["last_usage"] = stream_usage - if responses_response_id: - assistant_metadata["response_id"] = responses_response_id - if emitted_anything and not saw_final_chunk and not accumulated_text: - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status=_failed_status_for_resume(resume_input), - invocation_id=prepared.invocation_id, - detail="runner_stream_ended_without_final_output", - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - await _persist_accumulated_reasoning() - _finish_span() - yield { - "type": "error", - "message": "Agent 运行流已结束,但没有返回最终输出。", - "session_id": prepared.session_id, - "metadata": { - **assistant_metadata, - "detail": "runner_stream_ended_without_final_output", - }, - } - return - _set_conversation_output_attributes(span, accumulated_text) - - await _persist_accumulated_reasoning() - await append_conversation_event( - session_id=prepared.session_id, - author=runner_name, - role="model", - text=accumulated_text, - invocation_id=prepared.invocation_id, - event_type="assistant_message", - metadata=assistant_metadata or None, - session_service_provider=provider, - ) - await _update_session_metadata_after_assistant_turn( - service=provider(), - session_id=prepared.session_id, - assistant_text=accumulated_text, - model=model, - ) - await _auto_save_ltm_turn( - agent_id=agent_id, - user_id=user_id, - prepared=prepared, - output_text=accumulated_text, - runner_type=runtime_context.runner_type, - model=model, - ) - await append_run_status_event( - session_id=prepared.session_id, - author=runner_name, - status="completed", - invocation_id=prepared.invocation_id, - session_service_provider=provider, - run_mode=run_mode, - run_trigger=run_trigger, - ) - _set_conversation_usage_attributes(span, assistant_metadata.get("usage")) - _finish_span() - yield { - "type": "completed", - "output_text": accumulated_text, - "model": model, - "session_id": prepared.session_id, - "metadata": assistant_metadata, - "responses_output": responses_output, - "response_id": responses_response_id, - "usage": assistant_metadata.get("usage"), - } - finally: - _finish_span() - - -async def stream_conversation_turn( - *, - runner: Any, - agent_id: str, - user_id: str, - session_id: Optional[str], - messages: Sequence[Dict[str, Any]], - model: Optional[str], - prepare_runner: Callable[[Any, Optional[str]], None], - 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, - 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, -) -> AsyncIterator[str]: - """Legacy ksadk response SSE stream used by hosted chat and chat-completions.""" - 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, - resume_input=resume_input, - account_id=account_id, - invocation_id=invocation_id, - session_service_provider=session_service_provider, - run_mode=run_mode, - ): - event_type = event.get("type") - if event_type == "compaction": - yield build_compaction_sse_event( - phase=str(event.get("phase") or "start"), - trigger=str(event.get("trigger") or "auto"), - compacted_until_seq_id=event.get("compacted_until_seq_id"), - total_chars=event.get("total_chars"), - total_estimated_tokens=event.get("total_estimated_tokens"), - group_count=event.get("group_count"), - threshold_percentage=event.get("threshold_percentage"), - ) - elif event_type == "thinking": - yield _response_sse("response.reasoning.delta", {"delta": event.get("delta", "")}) - elif event_type == "text": - yield _response_sse("response.output_text.delta", {"delta": event.get("delta", "")}) - elif event_type == "tool_call": - yield _response_sse( - "response.tool_call", - { - "name": event.get("name"), - "args": event.get("args", {}), - "run_id": event.get("run_id"), - "stage": event.get("stage"), - "event_kind": event.get("event_kind"), - "display_title": event.get("display_title"), - "display_summary": event.get("display_summary"), - }, - ) - elif event_type == "tool_result": - yield _response_sse( - "response.tool_result", - { - "name": event.get("name"), - "output": event.get("output", ""), - "run_id": event.get("run_id"), - }, - ) - elif event_type in {"stage_tool_call", "stage_tool_result"}: - yield _response_sse( - f"response.ksadk.{event_type}", - { - "type": event_type, - "name": event.get("name"), - "args": event.get("args", {}), - "output": event.get("output", ""), - "run_id": event.get("run_id"), - "stage": event.get("stage"), - "event_kind": event.get("event_kind"), - "display_title": event.get("display_title"), - "display_summary": event.get("display_summary"), - }, - ) - elif event_type == "interrupt": - yield _response_sse( - "response.approval_request", {"interrupt_info": event.get("interrupt_info")} - ) - elif event_type == "error": - yield _response_sse( - "response.error", {"message": event.get("message") or "Agent 运行失败"} - ) - elif event_type == "cancelled": - yield _response_sse("response.cancelled", {"status": "cancelled"}) - elif event_type == "completed": - final_payload = build_responses_payload( - output_text=str(event.get("output_text") or ""), - model=event.get("model") or model, - session_id=str(event.get("session_id") or session_id or ""), - metadata=( - event.get("metadata") if isinstance(event.get("metadata"), Mapping) else None - ), - ) - yield _response_sse("response.completed", final_payload) - - -async def stream_responses_conversation_turn( - *, - runner: Any, - agent_id: str, - user_id: str, - session_id: Optional[str], - messages: Sequence[Dict[str, Any]], - model: Optional[str], - prepare_runner: Callable[[Any, Optional[str]], None], - 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, - 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, -) -> AsyncIterator[str]: - """OpenAI Responses-style SSE stream.""" - response_id = f"resp_{uuid.uuid4().hex}" - created_at = int(time.time()) - response_metadata = dict(request_metadata or {}) - - message_item_id = f"msg_{uuid.uuid4().hex[:12]}" - reasoning_item_id = f"rs_{uuid.uuid4().hex[:12]}" - next_output_index = 0 - text_output_index: int | None = None - reasoning_output_index: int | None = None - message_started = False - content_started = False - reasoning_started = False - completed_text = "" - lifecycle_started = False - - def _start_response_lifecycle(current_session_id: str | None = None) -> list[str]: - nonlocal lifecycle_started, response_metadata - if lifecycle_started: - return [] - lifecycle_started = True - initial_payload = build_responses_payload( - output_text="", - model=model, - session_id=current_session_id or session_id or "", - response_id=response_id, - created_at=created_at, - status="in_progress", - metadata=response_metadata, - ) - return [ - _response_sse("response.created", initial_payload), - _response_sse("response.in_progress", initial_payload), - ] - - def _message_item(status: str, text: str = "") -> dict[str, Any]: - content = [{"type": "output_text", "text": text}] if text or status == "completed" else [] - return { - "id": message_item_id, - "type": "message", - "status": status, - "role": "assistant", - "content": content, - } - - def _reasoning_item(status: str) -> dict[str, Any]: - return { - "id": reasoning_item_id, - "type": "reasoning", - "status": status, - "summary": [], - } - - def _next_output_index() -> int: - nonlocal next_output_index - output_index = next_output_index - 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, - 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, - ): - event_metadata = event.get("metadata") - if isinstance(event_metadata, Mapping): - response_metadata.update(dict(event_metadata)) - if not lifecycle_started: - for lifecycle_chunk in _start_response_lifecycle(str(event.get("session_id") or "")): - yield lifecycle_chunk - event_type = event.get("type") - if event_type == "compaction": - yield build_compaction_sse_event( - phase=str(event.get("phase") or "start"), - trigger=str(event.get("trigger") or "auto"), - compacted_until_seq_id=event.get("compacted_until_seq_id"), - total_chars=event.get("total_chars"), - total_estimated_tokens=event.get("total_estimated_tokens"), - group_count=event.get("group_count"), - threshold_percentage=event.get("threshold_percentage"), - ) - continue - - if event_type == "thinking": - if not reasoning_started: - reasoning_started = True - reasoning_output_index = _next_output_index() - yield _response_sse( - "response.output_item.added", - { - "output_index": reasoning_output_index, - "item": _reasoning_item("in_progress"), - }, - ) - yield _response_sse( - "response.reasoning.delta", - { - "item_id": reasoning_item_id, - "output_index": reasoning_output_index, - "delta": event.get("delta", ""), - }, - ) - continue - - if event_type == "text": - if not message_started: - message_started = True - text_output_index = _next_output_index() - yield _response_sse( - "response.output_item.added", - {"output_index": text_output_index, "item": _message_item("in_progress")}, - ) - if not content_started: - content_started = True - yield _response_sse( - "response.content_part.added", - { - "item_id": message_item_id, - "output_index": text_output_index, - "content_index": 0, - "part": {"type": "output_text", "text": ""}, - }, - ) - delta = str(event.get("delta") or "") - completed_text += delta - yield _response_sse( - "response.output_text.delta", - { - "item_id": message_item_id, - "output_index": text_output_index, - "content_index": 0, - "delta": delta, - }, - ) - continue - - if event_type == "tool_call": - args_json = json.dumps(event.get("args", {}) or {}, ensure_ascii=False) - call_id = str(event.get("run_id") or f"call_{uuid.uuid4().hex[:12]}") - item_id = f"fc_{uuid.uuid4().hex[:12]}" - call_output_index = _next_output_index() - item = { - "id": item_id, - "type": "function_call", - "status": "in_progress", - "call_id": call_id, - "name": event.get("name") or "unknown", - "arguments": "", - } - yield _response_sse( - "response.output_item.added", {"output_index": call_output_index, "item": item} - ) - yield _response_sse( - "response.function_call_arguments.delta", - {"item_id": item_id, "output_index": call_output_index, "delta": args_json}, - ) - item["arguments"] = args_json - item["status"] = "completed" - yield _response_sse( - "response.function_call_arguments.done", - {"item_id": item_id, "output_index": call_output_index, "arguments": args_json}, - ) - yield _response_sse( - "response.output_item.done", {"output_index": call_output_index, "item": item} - ) - if event.get("display_title") or event.get("display_summary"): - yield _response_sse( - "response.ksadk.tool_call", - { - "type": "tool_call", - "name": event.get("name"), - "args": event.get("args", {}), - "run_id": event.get("run_id"), - "stage": event.get("stage"), - "event_kind": event.get("event_kind"), - "display_title": event.get("display_title"), - "display_summary": event.get("display_summary"), - }, - ) - continue - - if event_type == "tool_result": - yield _response_sse( - "response.ksadk.tool_result", - { - "name": event.get("name"), - "output": event.get("output", ""), - "run_id": event.get("run_id"), - }, - ) - continue - - if event_type in {"stage_tool_call", "stage_tool_result"}: - yield _response_sse( - f"response.ksadk.{event_type}", - { - "type": event_type, - "name": event.get("name"), - "args": event.get("args", {}), - "output": event.get("output", ""), - "run_id": event.get("run_id"), - "stage": event.get("stage"), - "event_kind": event.get("event_kind"), - "display_title": event.get("display_title"), - "display_summary": event.get("display_summary"), - }, - ) - continue +_IMPL_MODULES = ( + _runtime_compaction, + _runtime_governance, + _runtime_observability, + _runtime_payloads, + _runtime_input, + _runtime_resume, + _runtime_metadata, + _runtime_persistence, + _runtime_preparation, + _runtime_invocation, + _runtime_stream_events, + _runtime_streaming, +) - if event_type == "interrupt": - interrupt_info = event.get("interrupt_info") - if isinstance(interrupt_info, Mapping) and interrupt_info.get("tool_name"): - raw_arguments = ( - interrupt_info.get("arguments") - or interrupt_info.get("tool_args") - or interrupt_info.get("args") - or {} - ) - arguments = ( - raw_arguments - if isinstance(raw_arguments, str) - else json.dumps(raw_arguments, ensure_ascii=False) - ) - approval_item = { - "id": str( - interrupt_info.get("approval_request_id") - or interrupt_info.get("id") - or f"appr_{uuid.uuid4().hex[:12]}" - ), - "type": "mcp_approval_request", - "name": str(interrupt_info.get("tool_name")), - "arguments": arguments, - "server_label": str(interrupt_info.get("server_label") or "ksadk"), - } - approval_output_index = _next_output_index() - yield _response_sse( - "response.output_item.added", - {"output_index": approval_output_index, "item": approval_item}, - ) - yield _response_sse( - "response.output_item.done", - {"output_index": approval_output_index, "item": approval_item}, - ) - else: - yield _response_sse( - "response.ksadk.approval_request", {"interrupt_info": interrupt_info} - ) - incomplete_payload = build_responses_payload( - output_text=completed_text, - model=model, - session_id=str(event.get("session_id") or session_id or ""), - response_id=response_id, - created_at=created_at, - status="incomplete", - metadata=response_metadata, - incomplete_details={ - "reason": "approval_required", - "ksadk_interrupt": interrupt_info, - }, - ) - yield _response_sse("response.incomplete", incomplete_payload) - return - if event_type == "error": - failed_payload = build_responses_payload( - output_text=completed_text, - model=model, - session_id=session_id or "", - response_id=response_id, - created_at=created_at, - status="failed", - metadata=response_metadata, - error={"message": event.get("message") or "Agent 运行失败"}, - ) - yield _response_sse("response.failed", failed_payload) - return +class _RuntimeFacadeModule(types.ModuleType): + """Keep historical runtime-module monkeypatches visible to moved implementations.""" - if event_type == "cancelled": - cancelled_payload = build_responses_payload( - output_text=completed_text, - model=model, - session_id=str(event.get("session_id") or session_id or ""), - response_id=response_id, - created_at=created_at, - status="cancelled", - metadata=( - event.get("metadata") - if isinstance(event.get("metadata"), Mapping) - else response_metadata - ), - ) - yield _response_sse("response.cancelled", cancelled_payload) - return + def __setattr__(self, name: str, value: Any) -> None: + super().__setattr__(name, value) + for module in _IMPL_MODULES: + if name in module.__dict__: + setattr(module, name, value) - if event_type == "completed": - completed_text = str(event.get("output_text") or completed_text) - if completed_text and not message_started: - message_started = True - text_output_index = _next_output_index() - yield _response_sse( - "response.output_item.added", - {"output_index": text_output_index, "item": _message_item("in_progress")}, - ) - content_started = True - yield _response_sse( - "response.content_part.added", - { - "item_id": message_item_id, - "output_index": text_output_index, - "content_index": 0, - "part": {"type": "output_text", "text": ""}, - }, - ) - if message_started: - yield _response_sse( - "response.output_text.done", - { - "item_id": message_item_id, - "output_index": text_output_index, - "content_index": 0, - "text": completed_text, - }, - ) - yield _response_sse( - "response.content_part.done", - { - "item_id": message_item_id, - "output_index": text_output_index, - "content_index": 0, - "part": {"type": "output_text", "text": completed_text}, - }, - ) - yield _response_sse( - "response.output_item.done", - { - "output_index": text_output_index, - "item": _message_item("completed", completed_text), - }, - ) - if reasoning_started: - yield _response_sse( - "response.output_item.done", - {"output_index": reasoning_output_index, "item": _reasoning_item("completed")}, - ) - final_payload = build_responses_payload( - output_text=completed_text, - model=event.get("model") or model, - session_id=str(event.get("session_id") or session_id or ""), - response_id=str(event.get("response_id") or response_id), - created_at=created_at, - status="completed", - metadata=( - event.get("metadata") - if isinstance(event.get("metadata"), Mapping) - else response_metadata - ), - output_items=( - event.get("responses_output") - if isinstance(event.get("responses_output"), Sequence) - else None - ), - ) - yield _response_sse("response.completed", final_payload) - return - if not lifecycle_started: - for lifecycle_chunk in _start_response_lifecycle(): - yield lifecycle_chunk +sys.modules[__name__].__class__ = _RuntimeFacadeModule diff --git a/ksadk/conversations/runtime_compaction.py b/ksadk/conversations/runtime_compaction.py new file mode 100644 index 00000000..a1737a7a --- /dev/null +++ b/ksadk/conversations/runtime_compaction.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import uuid +from typing import Any, Callable, Dict, Mapping, Optional, Sequence + +from ksadk.conversations.compaction_pipeline import build_working_set_metadata, run_pipeline +from ksadk.conversations.context import ( + TRANSCRIPT_EVENT_TYPES, + canonical_event_type, + compacted_until_seq_id, + extract_event_text, + group_events_by_api_round, +) +from ksadk.conversations.model_context import ( + estimate_text_tokens, + get_auto_compact_threshold_percentage, + get_auto_compact_threshold_tokens, +) +from ksadk.conversations.runtime_constants import ( + AUTOCOMPACT_KEEP_TAIL_GROUPS, + PTL_RETRY_KEEP_TAIL_GROUPS, +) +from ksadk.conversations.runtime_metadata import ( + _build_pending_user_event, + _latest_user_turn, + _normalized_conversation_messages, + _resolve_effective_attachment_context, + _resolve_model_metadata, + _resolve_runtime_model_metadata, + _transcript_event_type, +) +from ksadk.conversations.runtime_payloads import CompactionPlan +from ksadk.conversations.runtime_persistence import append_context_checkpoint_event +from ksadk.conversations.semantic_summary import ( + extract_pinned_state, + find_pinned_group_indexes, + summarize_compaction, +) +from ksadk.sessions import SessionEvent, resolve_session_service + + +def _plan_compaction( + events: Sequence[SessionEvent], + *, + model: Optional[str] = None, + model_metadata: Mapping[str, Any] | None = None, + pending_events: Sequence[SessionEvent] | None = None, + force: bool = False, + keep_tail_groups: int | None = None, +) -> CompactionPlan: + """根据当前 transcript 计算是否需要做 checkpoint compaction。""" + + compacted_until = compacted_until_seq_id(list(events)) + transcript_events = [ + event + for event in events + if event.seq_id > compacted_until + and _transcript_event_type(event) in TRANSCRIPT_EVENT_TYPES + and _transcript_event_type(event) != "context_checkpoint" + ] + pending_transcript_events = [ + event + for event in (pending_events or []) + if _transcript_event_type(event) in TRANSCRIPT_EVENT_TYPES + and _transcript_event_type(event) != "context_checkpoint" + ] + combined_events = [*transcript_events, *pending_transcript_events] + groups = group_events_by_api_round(combined_events) + pinned_group_indexes = sorted(find_pinned_group_indexes(groups)) + pinned_state = extract_pinned_state(groups) + tail_groups = ( + keep_tail_groups + if keep_tail_groups is not None + else (PTL_RETRY_KEEP_TAIL_GROUPS if force else AUTOCOMPACT_KEEP_TAIL_GROUPS) + ) + resolved_model_metadata = _resolve_model_metadata(model, model_metadata=model_metadata) + auto_compact_threshold_tokens = get_auto_compact_threshold_tokens(resolved_model_metadata) + auto_compact_threshold_percentage = get_auto_compact_threshold_percentage( + resolved_model_metadata + ) + total_chars = sum(len(extract_event_text(event)) for event in combined_events) + total_estimated_tokens = sum( + estimate_text_tokens(extract_event_text(event)) for event in combined_events + ) + if not force and ( + len(groups) <= tail_groups or total_estimated_tokens <= auto_compact_threshold_tokens + ): + return CompactionPlan( + should_compact=False, + groups_to_compact=[], + total_chars=total_chars, + total_estimated_tokens=total_estimated_tokens, + group_count=len(groups), + tail_groups=tail_groups, + auto_compact_threshold_tokens=auto_compact_threshold_tokens, + auto_compact_threshold_percentage=auto_compact_threshold_percentage, + pinned_group_indexes=pinned_group_indexes, + pinned_state=pinned_state, + ) + + compactable_indexes = [ + index for index in range(len(groups)) if index not in pinned_group_indexes + ] + retained_tail_indexes = set(compactable_indexes[-tail_groups:]) if tail_groups > 0 else set() + preserved_indexes = set(pinned_group_indexes) | retained_tail_indexes + first_preserved_index = min(preserved_indexes) if preserved_indexes else len(groups) + groups_to_compact = [ + group + for index, group in enumerate(groups[:first_preserved_index]) + if index not in pinned_group_indexes + ] + if not groups_to_compact: + return CompactionPlan( + should_compact=False, + groups_to_compact=[], + total_chars=total_chars, + total_estimated_tokens=total_estimated_tokens, + group_count=len(groups), + tail_groups=tail_groups, + auto_compact_threshold_tokens=auto_compact_threshold_tokens, + auto_compact_threshold_percentage=auto_compact_threshold_percentage, + pinned_group_indexes=pinned_group_indexes, + pinned_state=pinned_state, + ) + + compacted_until_seq_id_value = groups_to_compact[-1][-1].seq_id or None + return CompactionPlan( + should_compact=True, + groups_to_compact=groups_to_compact, + total_chars=total_chars, + total_estimated_tokens=total_estimated_tokens, + group_count=len(groups), + tail_groups=tail_groups, + auto_compact_threshold_tokens=auto_compact_threshold_tokens, + auto_compact_threshold_percentage=auto_compact_threshold_percentage, + compacted_until_seq_id=compacted_until_seq_id_value, + pinned_group_indexes=pinned_group_indexes, + pinned_state=pinned_state, + ) + + +async def preview_auto_compaction( + *, + agent_id: str, + user_id: str, + session_id: Optional[str], + messages: Sequence[Dict[str, Any]], + model: Optional[str] = None, + model_metadata: Mapping[str, Any] | None = None, + session_service_provider: Callable[[], Any] | None = None, +) -> CompactionPlan: + """在真正写入 turn 之前预估是否会触发自动压缩。 + + 这个预览只用于给 UI 提前打一条“正在压缩上下文”的流式提示,不会修改会话。 + """ + + if not session_id: + return CompactionPlan( + should_compact=False, + groups_to_compact=[], + total_chars=0, + total_estimated_tokens=0, + group_count=0, + tail_groups=AUTOCOMPACT_KEEP_TAIL_GROUPS, + ) + + provider = session_service_provider or resolve_session_service + service = provider() + existing_session = await service.get_session(session_id) + if not existing_session: + return CompactionPlan( + should_compact=False, + groups_to_compact=[], + total_chars=0, + total_estimated_tokens=0, + group_count=0, + tail_groups=AUTOCOMPACT_KEEP_TAIL_GROUPS, + ) + + resolved_user_id = existing_session.user_id or user_id + if existing_session.agent_id != agent_id or resolved_user_id != user_id: + return CompactionPlan( + should_compact=False, + groups_to_compact=[], + total_chars=0, + total_estimated_tokens=0, + group_count=0, + tail_groups=AUTOCOMPACT_KEEP_TAIL_GROUPS, + ) + + normalized_messages = _normalized_conversation_messages(messages) + resolved_model_metadata = await _resolve_runtime_model_metadata( + model, + model_metadata=model_metadata, + ) + user_input, user_display_input, _, _, attachments, attachment_results = _latest_user_turn( + normalized_messages + ) + effective_attachments, effective_attachment_results = _resolve_effective_attachment_context( + normalized_messages=normalized_messages, + session=existing_session, + ) + pending_event = _build_pending_user_event( + session_id=session_id, + invocation_id=f"preview-{uuid.uuid4()}", + user_input=user_input, + user_display_input=user_display_input or user_input, + attachments=effective_attachments, + attachment_results=effective_attachment_results, + ) + events = await service.get_events(session_id) + return _plan_compaction( + events, + model=model, + model_metadata=resolved_model_metadata, + pending_events=[pending_event], + ) + + +async def compact_conversation_history( + *, + session_id: str, + author: str, + invocation_id: Optional[str] = None, + model: Optional[str] = None, + model_metadata: Mapping[str, Any] | None = None, + force: bool = False, + trigger: str = "auto", + keep_tail_groups: Optional[int] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent | None: + """把旧轮次折叠为 checkpoint。 + + 这是本地版的 compaction:先按 API round 分组,再保留尾部若干轮,把更早 + 的部分压成 append-only summary 事件。force=True 时用于 PTL 恢复。 + """ + provider = session_service_provider or resolve_session_service + service = provider() + events = await service.get_events(session_id) + plan = _plan_compaction( + events, + model=model, + model_metadata=model_metadata, + force=force, + keep_tail_groups=keep_tail_groups, + ) + if not plan.should_compact: + return None + + previous_summary = "" + latest_checkpoint = next( + ( + event + for event in reversed(events) + if canonical_event_type(event.event_type) == "context_checkpoint" + ), + None, + ) + if latest_checkpoint: + previous_summary = extract_event_text(latest_checkpoint) + + compacted_until_seq_id_value = int(plan.compacted_until_seq_id or 0) + resolved_model_metadata = _resolve_model_metadata(model, model_metadata=model_metadata) + threshold_tokens = get_auto_compact_threshold_tokens(resolved_model_metadata) + + # P0.5 分层 compaction pipeline:L2 Snip + L3 Microcompact(零 LLM 成本确定性裁剪), + # 只作用于送 summarizer 的 candidate 投影,绝不删 append-only transcript。 + # 详见 ksadk/conversations/compaction_pipeline.py。 + pipeline_result = run_pipeline( + plan.groups_to_compact, + threshold_tokens=threshold_tokens, + pinned_state=plan.pinned_state, + previous_summary=previous_summary, + ) + candidate_groups = pipeline_result["candidate_groups"] + + summary_result = await summarize_compaction( + groups_to_compact=candidate_groups, + previous_summary=previous_summary, + pinned_state=plan.pinned_state, + model_metadata=resolved_model_metadata, + model=model, + ) + + # L5 working set 恢复(保守版):只记 metadata,不读文件内容。 + working_set = build_working_set_metadata(pinned_state=plan.pinned_state) + + return await append_context_checkpoint_event( + session_id=session_id, + author=author, + compacted_until_seq_id=compacted_until_seq_id_value, + summary_text=summary_result.summary_text, + trigger=trigger, + invocation_id=invocation_id, + metadata={ + "head_seq_id": plan.groups_to_compact[0][0].seq_id, + "tail_seq_id": plan.groups_to_compact[-1][-1].seq_id, + "invocation_ids": [ + event.invocation_id + for group in plan.groups_to_compact + for event in group + if event.invocation_id + ], + "summary_strategy": summary_result.summary_strategy, + "summary_version": summary_result.summary_version, + "summary_model": summary_result.summary_model, + "summary_usage": summary_result.summary_usage, + "fallback_reason": summary_result.fallback_reason, + # P0.5 pipeline 审计字段(原始 transcript 未改,这里只记投影统计)。 + "pipeline_stages": pipeline_result["pipeline_stages"], + "tokens_before": pipeline_result["tokens_before"], + "tokens_after": pipeline_result["tokens_after"], + "snip_stats": { + "removed_redundant_tool_results": pipeline_result[ + "snip_stats" + ].removed_redundant_tool_results, + "snip_released_tokens": pipeline_result["snip_released_tokens"], + "covered_seq_range": list(pipeline_result["snip_stats"].covered_seq_range or []), + }, + "microcompact_stats": ( + { + "compacted_groups": pipeline_result["microcompact_stats"].compacted_groups, + "tokens_before": pipeline_result["microcompact_stats"].tokens_before, + "tokens_after": pipeline_result["microcompact_stats"].tokens_after, + "preserved_receipts": pipeline_result["microcompact_stats"].preserved_receipts, + } + if pipeline_result["microcompact_stats"] is not None + else None + ), + "working_set": working_set, + }, + session_service_provider=provider, + ) diff --git a/ksadk/conversations/runtime_constants.py b/ksadk/conversations/runtime_constants.py new file mode 100644 index 00000000..93706c24 --- /dev/null +++ b/ksadk/conversations/runtime_constants.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import logging +from typing import Any + +AUTOCOMPACT_KEEP_TAIL_GROUPS = 4 +PTL_RETRY_KEEP_TAIL_GROUPS = 2 +PROMPT_TOO_LONG_MARKERS = ( + "prompt-too-long", + "prompt too long", + "maximum context length", + "context length", + "context_length_exceeded", + "413", +) +SESSION_SUMMARY_MAX_CHARS = 160 +ATTACHMENT_CONTEXT_STATE_KEY = "__ksadk_attachment_context__" +EVENT_SCAN_PAGE_SIZE = 500 +# Durable stream snapshots make a detached run recoverable after a browser refresh. +# Keep them bounded: the first text delta is persisted immediately, then at most one +# update per interval unless a substantial amount of text has arrived. +ASSISTANT_STREAM_SNAPSHOT_INTERVAL_SECONDS = 0.5 +ASSISTANT_STREAM_SNAPSHOT_MIN_NEW_CHARS = 256 + +logger = logging.getLogger("ksadk.conversations.runtime") +_MODEL_CATALOG_CACHE_TTL_SECONDS = 60.0 +_MODEL_CATALOG_CACHE: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {} diff --git a/ksadk/conversations/runtime_governance.py b/ksadk/conversations/runtime_governance.py new file mode 100644 index 00000000..9d188245 --- /dev/null +++ b/ksadk/conversations/runtime_governance.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from typing import Any, Mapping + +from ksadk.sessions import SessionEvent + + +@dataclass +class RuntimeGovernanceState: + max_turns: int = 0 + max_tool_calls: int = 0 + max_consecutive_tool_failures: int = 0 + max_consecutive_approval_denials: int = 0 + max_consecutive_compact_failures: int = 0 + turn_count: int = 0 + tool_calls: int = 0 + consecutive_tool_failures: int = 0 + consecutive_approval_denials: int = 0 + consecutive_compact_failures: int = 0 + + +class RuntimeCircuitOpen(RuntimeError): + def __init__(self, reason: str, message: str, *, metadata: Mapping[str, Any] | None = None): + super().__init__(message) + self.reason = reason + self.metadata = dict(metadata or {}) + + +def _int_env(name: str, default: int = 0) -> int: + raw = os.environ.get(name) + if raw is None: + return int(default) + try: + return max(0, int(raw)) + except ValueError: + return int(default) + + +def _runtime_governance_from_env() -> RuntimeGovernanceState: + return RuntimeGovernanceState( + max_turns=_int_env("KSADK_MAX_TURNS", 0), + max_tool_calls=_int_env("KSADK_MAX_TOOL_CALLS", 0), + max_consecutive_tool_failures=_int_env("KSADK_MAX_CONSECUTIVE_TOOL_FAILURES", 0), + max_consecutive_approval_denials=_int_env("KSADK_MAX_CONSECUTIVE_APPROVAL_DENIALS", 0), + max_consecutive_compact_failures=_int_env("KSADK_MAX_CONSECUTIVE_COMPACT_FAILURES", 0), + ) + + +def _governance_error( + reason: str, message: str, state: RuntimeGovernanceState +) -> RuntimeCircuitOpen: + return RuntimeCircuitOpen( + reason, + message, + metadata={ + "reason": reason, + "max_turns": state.max_turns, + "max_tool_calls": state.max_tool_calls, + "max_consecutive_tool_failures": state.max_consecutive_tool_failures, + "max_consecutive_approval_denials": state.max_consecutive_approval_denials, + "max_consecutive_compact_failures": state.max_consecutive_compact_failures, + "turn_count": state.turn_count, + "tool_calls": state.tool_calls, + "consecutive_tool_failures": state.consecutive_tool_failures, + "consecutive_approval_denials": state.consecutive_approval_denials, + "consecutive_compact_failures": state.consecutive_compact_failures, + }, + ) + + +def _governance_record_turn_start(state: RuntimeGovernanceState) -> None: + state.turn_count += 1 + if state.max_turns and state.turn_count > state.max_turns: + raise _governance_error("max_turns_exceeded", "runtime max_turns limit exceeded", state) + + +def _governance_record_tool_call(state: RuntimeGovernanceState) -> None: + state.tool_calls += 1 + if state.max_tool_calls and state.tool_calls > state.max_tool_calls: + raise _governance_error( + "max_tool_calls_exceeded", "runtime max_tool_calls limit exceeded", state + ) + + +def _governance_record_tool_result(state: RuntimeGovernanceState, output: Any) -> None: + failed = isinstance(output, Mapping) and output.get("ok") is False + state.consecutive_tool_failures = state.consecutive_tool_failures + 1 if failed else 0 + if ( + state.max_consecutive_tool_failures + and state.consecutive_tool_failures >= state.max_consecutive_tool_failures + ): + raise _governance_error( + "consecutive_tool_failures", "runtime consecutive tool failure limit exceeded", state + ) + + +def _governance_record_approval_response( + state: RuntimeGovernanceState, approval: Mapping[str, Any] +) -> None: + approved = bool(approval.get("approved") or approval.get("approve")) + state.consecutive_approval_denials = 0 if approved else state.consecutive_approval_denials + 1 + if ( + state.max_consecutive_approval_denials + and state.consecutive_approval_denials >= state.max_consecutive_approval_denials + ): + raise _governance_error( + "consecutive_approval_denials", + "runtime consecutive approval denial limit exceeded", + state, + ) + + +def _governance_record_compact_failure(state: RuntimeGovernanceState) -> None: + state.consecutive_compact_failures += 1 + if ( + state.max_consecutive_compact_failures + and state.consecutive_compact_failures >= state.max_consecutive_compact_failures + ): + raise _governance_error( + "consecutive_compact_failures", + "runtime consecutive compact failure limit exceeded", + state, + ) + + +def _governance_record_compact_success(state: RuntimeGovernanceState) -> None: + state.consecutive_compact_failures = 0 + + +async def _compact_conversation_history_with_governance( + governance: RuntimeGovernanceState | None, + **kwargs: Any, +) -> SessionEvent | None: + from ksadk.conversations.runtime_compaction import compact_conversation_history + + try: + checkpoint = await compact_conversation_history(**kwargs) + except Exception: + if governance is not None: + _governance_record_compact_failure(governance) + raise + if checkpoint is not None and governance is not None: + _governance_record_compact_success(governance) + return checkpoint + + +def _tool_observability_metadata( + tool_name: str, output: Any, *, duration_ms: int | None = None +) -> dict[str, Any]: + output_text = ( + json.dumps(output, ensure_ascii=False, sort_keys=True) + if isinstance(output, Mapping) + else str(output) + ) + metadata: dict[str, Any] = { + "tool_name": tool_name, + "duration_ms": int(duration_ms or 0), + "output_chars": len(output_text), + "truncated": False, + "persisted": False, + "exit_code": None, + "error_type": "", + } + if isinstance(output, Mapping): + persisted = output.get("persisted") or output.get("persisted_outputs") + metadata.update( + { + "truncated": any( + bool(output.get(key)) + for key in ( + "truncated", + "stdout_truncated", + "stderr_truncated", + "results_truncated", + ) + ), + "persisted": bool(persisted), + "exit_code": output.get("exit_code"), + "error_type": str(output.get("error_type") or ""), + } + ) + return metadata diff --git a/ksadk/conversations/runtime_input.py b/ksadk/conversations/runtime_input.py new file mode 100644 index 00000000..9059dbc7 --- /dev/null +++ b/ksadk/conversations/runtime_input.py @@ -0,0 +1,652 @@ +from __future__ import annotations + +import json +import os +from typing import Any, Mapping, Sequence + +from ksadk.conversations.reasoning_markup import strip_reasoning_markup +from ksadk.conversations.runtime_constants import ( + PROMPT_TOO_LONG_MARKERS, + logger, +) +from ksadk.conversations.runtime_observability import _extract_deferred_tool_names +from ksadk.conversations.runtime_payloads import PreparedConversationTurn +from ksadk.conversations.runtime_resume import _is_checkpoint_resume_input +from ksadk.knowledge_base.service import KnowledgeBaseService +from ksadk.memory.service import LongTermMemoryService +from ksadk.runtime_context import ( + PlatformInvocationContext, +) + + +def _is_prompt_too_long_error(exc: Exception) -> bool: + """尽量用宽松规则识别 PTL,兼容不同 runtime/模型返回格式。""" + lowered = str(exc or "").lower() + return any(marker in lowered for marker in PROMPT_TOO_LONG_MARKERS) + + +def _runner_name(runner: Any) -> str: + return str(getattr(getattr(runner, "detection_result", None), "name", "assistant")) + + +def _runner_type_name(runner: Any) -> str: + runner_type = getattr(getattr(runner, "detection_result", None), "type", None) + runner_value = getattr(runner_type, "value", runner_type) + normalized = str(runner_value or "").strip() + if normalized: + return normalized + return str(runner.__class__.__name__).lower() + + +def _env_flag(name: str, default: bool = True) -> bool: + raw = os.getenv(name) + if raw is None: + return default + normalized = str(raw).strip().lower() + if not normalized: + return default + return normalized not in {"0", "false", "no", "off"} + + +def _ltm_auto_save_enabled() -> bool: + backend = str(os.getenv("KSADK_LTM_BACKEND") or "").strip().lower() + namespace = str(os.getenv("KSADK_LTM_NAMESPACE") or "").strip() + if not backend and not namespace: + return False + default = backend == "sdk" and bool(namespace) + return _env_flag("KSADK_LTM_AUTO_SAVE", default) + + +def _ambient_policy(prefix: str, default: str = "on_demand") -> str: + if not _env_flag(f"{prefix}_AMBIENT_ENABLED", True): + return "disabled" + + raw = str(os.getenv(f"{prefix}_AMBIENT_POLICY", default) or "").strip().lower() + if raw in {"", "on_demand", "ondemand", "heuristic", "auto"}: + return "on_demand" + if raw in {"always", "eager"}: + return "always" + if raw in {"disabled", "off", "false", "0"}: + return "disabled" + return default + + +def _normalize_ambient_query(text: str) -> str: + return " ".join(str(text or "").strip().lower().split()) + + +def _contains_any_fragment(text: str, fragments: Sequence[str]) -> bool: + return any(fragment in text for fragment in fragments) + + +def _ambient_context_has_error(context: Any) -> bool: + if not isinstance(context, dict): + return True + + formatted_text = str(context.get("formatted_text") or "").strip() + if not formatted_text: + return True + + failure_prefixes = ( + "知识库检索失败", + "长期记忆检索失败", + ) + return formatted_text.startswith(failure_prefixes) + + +def _is_chitchat_query(text: str) -> bool: + normalized = _normalize_ambient_query(text) + if not normalized: + return True + + exact_matches = { + "hi", + "hello", + "hey", + "你好", + "您好", + "嗨", + "在吗", + "收到", + "好的", + "ok", + "okay", + "thanks", + "thank you", + "谢谢", + "测试", + "test", + "ping", + } + if normalized in exact_matches: + return True + + chatter_fragments = ( + "介绍一下你自己", + "介绍你自己", + "你是谁", + "你能做什么", + "what can you do", + "who are you", + "introduce yourself", + "一句测试", + ) + return any(fragment in normalized for fragment in chatter_fragments) + + +def _should_load_kb_ambient_context(user_input: str) -> bool: + normalized = _normalize_ambient_query(user_input) + if not normalized or _is_chitchat_query(normalized): + return False + + kb_fragments = ( + "知识库", + "文档", + "手册", + "说明", + "wiki", + "资料", + "教程", + "api", + "接口", + "参数", + "配置", + "规格", + "机型", + "实例", + "部署", + "步骤", + "区别", + "差异", + "原理", + "架构", + "能力", + "限制", + "最佳实践", + "价格", + "套餐", + "支持", + "有哪些", + "什么是", + "为什么", + "怎么", + "如何", + "查询", + "查一下", + "列一下", + "介绍一下", + "总结", + "概述", + "解释", + "说明一下", + "对比", + "比较", + "what", + "how", + "why", + "which", + "list", + "show me", + "tell me", + "summarize", + "summary", + "explain", + "difference", + "steps", + "deployment", + "lookup", + "look up", + "search", + "compare", + ) + if _contains_any_fragment(normalized, kb_fragments): + return True + + query_verbs = ( + "查", + "查一下", + "查询", + "列出", + "列一下", + "总结", + "概述", + "解释", + "说明", + "介绍", + "对比", + "比较", + "看看", + "告诉我", + "what", + "how", + "why", + "which", + "list", + "show", + "tell", + "summarize", + "explain", + "compare", + ) + kb_subjects = ( + "知识库", + "文档", + "手册", + "教程", + "wiki", + "部署", + "步骤", + "api", + "接口", + "参数", + "配置", + "规格", + "机型", + "实例", + "价格", + "套餐", + "支持", + "区别", + "差异", + "原理", + "架构", + "能力", + "限制", + ) + return _contains_any_fragment(normalized, query_verbs) and _contains_any_fragment( + normalized, kb_subjects + ) + + +def _should_load_memory_ambient_context(user_input: str) -> bool: + normalized = _normalize_ambient_query(user_input) + if not normalized or _is_chitchat_query(normalized): + return False + + explicit_memory_fragments = ( + "记得", + "记住", + "记忆", + "回忆", + "历史", + "偏好", + "习惯", + "还记得", + "记得我", + "记住这个", + "remember", + "memory", + "recall", + "history", + "preference", + ) + profile_fragments = ( + "我的名字", + "我叫什么", + "你知道我的名字", + "我的风格", + "按我的风格", + "按照我的风格", + "我的偏好", + "我的习惯", + "我的背景", + "关于我的", + "我喜欢", + "我不喜欢", + "my name", + "my style", + "my preference", + "about me", + ) + short_term_fragments = ( + "前面的回答", + "前面的内容", + "上面的回答", + "上面的内容", + "刚才的回答", + "刚刚的回答", + "上一条", + "上一轮", + "继续刚才", + "继续上面", + "翻译成英文", + "翻译成中文", + ) + temporal_fragments = ("上次", "之前", "以前", "earlier", "last time", "previous") + speech_fragments = ("聊过", "说过", "提过", "告诉过", "mentioned", "told") + + if _contains_any_fragment(normalized, short_term_fragments) and not _contains_any_fragment( + normalized, profile_fragments + ): + return False + + if _contains_any_fragment(normalized, explicit_memory_fragments) or _contains_any_fragment( + normalized, profile_fragments + ): + return True + + return _contains_any_fragment(normalized, temporal_fragments) and _contains_any_fragment( + normalized, speech_fragments + ) + + +def _should_use_platform_ambient_context(runner: Any) -> bool: + detection_type = getattr(getattr(runner, "detection_result", None), "type", None) + runner_type = str(getattr(detection_type, "value", detection_type) or "").strip().lower() + if runner_type: + return runner_type != "adk" + + class_name = runner.__class__.__name__.lower() + module_name = getattr(runner.__class__, "__module__", "").lower() + return class_name != "adkrunner" and "google_adk" not in module_name + + +def _build_runner_ambient_contexts( + *, + runner: Any, + user_id: str, + user_input: str, +) -> dict[str, Any]: + contexts: dict[str, Any] = { + "kb_context": None, + "memory_context": None, + } + normalized_input = str(user_input or "").strip() + if not normalized_input or not _should_use_platform_ambient_context(runner): + return contexts + + kb_policy = _ambient_policy("KSADK_KB", "on_demand") + if ( + kb_policy == "always" + or (kb_policy == "on_demand" and _should_load_kb_ambient_context(normalized_input)) + ) and KnowledgeBaseService.is_configured(): + try: + kb_context = KnowledgeBaseService.from_env().build_context(normalized_input) + if not _ambient_context_has_error(kb_context): + contexts["kb_context"] = kb_context + except Exception as exc: + logger.warning("Failed to build ambient knowledge context: %s", exc) + + ltm_policy = _ambient_policy("KSADK_LTM", "on_demand") + if ( + ltm_policy == "always" + or (ltm_policy == "on_demand" and _should_load_memory_ambient_context(normalized_input)) + ) and LongTermMemoryService.is_configured(): + try: + memory_context = LongTermMemoryService.from_env().build_context( + user_id=user_id, + query=normalized_input, + ) + if not _ambient_context_has_error(memory_context): + contexts["memory_context"] = memory_context + except Exception as exc: + logger.warning("Failed to build ambient memory context: %s", exc) + + return contexts + + +def _build_runner_request_payload( + *, + prepared: PreparedConversationTurn, + model: str | None, + runtime_context: PlatformInvocationContext, + runner: Any | None = None, +) -> dict[str, Any]: + payload = { + "session_id": prepared.session_id, + "input": prepared.user_input, + "history": prepared.history, + "input_content": prepared.input_content, + "input_messages": prepared.input_messages, + "input_parts": prepared.user_parts, + "attachments": prepared.attachments, + "attachment_results": prepared.attachment_results, + "current_attachments": prepared.current_attachments, + "current_attachment_results": prepared.current_attachment_results, + "has_current_files": prepared.has_current_files, + "model": model, + "model_metadata": prepared.model_metadata, + "model_options": prepared.model_options, + "platform_context": runtime_context.to_payload(), + "kb_context": runtime_context.kb_context, + "memory_context": runtime_context.memory_context, + "invocation_id": prepared.invocation_id, + } + if prepared.request_metadata: + # Keep endpoint-level controls available to an application entrypoint + # (for example, its conversation approval profile) without leaking + # caller public metadata into the agent payload. + payload["request_metadata"] = dict(prepared.request_metadata) + if prepared.instructions: + payload["instructions"] = prepared.instructions + if prepared.resume_input is not None: + if _is_checkpoint_resume_input(prepared.resume_input): + payload["input"] = prepared.resume_input + payload["checkpoint_resume"] = True + payload["run_id"] = str(prepared.resume_input.get("run_id") or "") + payload["checkpoint_id"] = str(prepared.resume_input.get("checkpoint_id") or "") + payload["framework_ref"] = dict(prepared.resume_input.get("framework_ref") or {}) + payload["metadata"] = dict(prepared.resume_input.get("metadata") or {}) + payload["checkpoint_metadata"] = dict( + prepared.resume_input.get("checkpoint_metadata") or {} + ) + else: + payload["input"] = prepared.resume_input + payload["resume"] = True + previous_response_id = prepared.request_metadata.get("previous_response_id") + if previous_response_id: + payload["previous_response_id"] = str(previous_response_id) + conversation = prepared.request_metadata.get("conversation") + if conversation: + payload["conversation"] = conversation + if prepared.request_metadata.get("responses_conversation"): + payload["responses_conversation"] = True + if getattr(runner, "api_format", "") == "responses" and prepared.responses_history: + payload["responses_history"] = list(prepared.responses_history) + deferred_tool_names = _extract_deferred_tool_names(prepared.request_metadata) + if deferred_tool_names: + payload["deferred_tool_names"] = deferred_tool_names + return payload + + +def _inject_runner_deferred_tools_for_request( + runner: Any, prepared: PreparedConversationTurn +) -> None: + deferred_tool_names = _extract_deferred_tool_names(prepared.request_metadata) + if not deferred_tool_names: + return + inject = getattr(runner, "inject_deferred_tools_for_request", None) + if not callable(inject): + return + try: + inject(deferred_tool_names) + except Exception as exc: + logger.warning("Failed to inject deferred tools into runner: %s", exc) + + +def _attachment_summary_for_memory( + attachments: Sequence[Mapping[str, Any]], + attachment_results: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + summaries: list[dict[str, Any]] = [] + for item in attachment_results: + if not isinstance(item, Mapping): + continue + summary = { + "kind": str(item.get("kind") or "file"), + "display_name": str( + item.get("display_name") or item.get("filename") or "uploaded_file" + ), + "mime_type": str(item.get("mime_type") or "application/octet-stream"), + } + summaries.append(summary) + + if summaries: + return summaries + + for item in attachments: + if not isinstance(item, Mapping): + continue + mime_type = str(item.get("mime_type") or "application/octet-stream") + display_name = str(item.get("display_name") or item.get("filename") or "uploaded_file") + kind = "image" if mime_type.startswith("image/") else "file" + summaries.append( + { + "kind": kind, + "display_name": display_name, + "mime_type": mime_type, + } + ) + return summaries + + +def _memory_turn_event_strings( + *, + prepared: PreparedConversationTurn, + output_text: str, + metadata: Mapping[str, Any], +) -> list[str]: + event_strings: list[str] = [] + user_text = _input_text_for_memory(prepared) + if user_text: + user_metadata = dict(metadata) + attachment_summary = _attachment_summary_for_memory( + prepared.current_attachments, + prepared.current_attachment_results, + ) + if attachment_summary: + user_metadata["attachments"] = attachment_summary + event_strings.append( + json.dumps( + { + "role": "user", + "parts": [{"text": user_text}], + "metadata": user_metadata, + }, + ensure_ascii=False, + ) + ) + + assistant_text = strip_reasoning_markup(str(output_text or "")).strip() + if assistant_text: + event_strings.append( + json.dumps( + { + "role": "assistant", + "parts": [{"text": assistant_text}], + "metadata": dict(metadata), + }, + ensure_ascii=False, + ) + ) + return event_strings + + +def _input_text_for_memory(prepared: PreparedConversationTurn) -> str: + text_parts: list[str] = [] + for item in prepared.input_content: + if isinstance(item, Mapping) and item.get("type") == "input_text": + text = str(item.get("text") or "").strip() + if text: + text_parts.append(text) + if text_parts: + return "\n".join(text_parts).strip() + return str(prepared.user_input or prepared.user_display_input or "").strip() + + +async def _auto_save_ltm_turn( + *, + agent_id: str, + user_id: str, + prepared: PreparedConversationTurn, + output_text: str, + runner_type: str, + model: str | None, +) -> None: + if prepared.resume_input is not None or not _ltm_auto_save_enabled(): + return + + metadata: dict[str, Any] = { + "agent_id": str(agent_id or ""), + "session_id": prepared.session_id, + "invocation_id": prepared.invocation_id, + "runner_type": runner_type, + } + if model: + metadata["model"] = model + + platform_context = prepared.request_metadata.get("platform_context") + if isinstance(platform_context, Mapping): + metadata["agent_id"] = str(platform_context.get("agent_id") or metadata["agent_id"]) + + if not metadata["agent_id"]: + metadata["agent_id"] = str(os.getenv("KSADK_LTM_AGENT_ID") or "") + + event_strings = _memory_turn_event_strings( + prepared=prepared, + output_text=output_text, + metadata=metadata, + ) + if not event_strings: + return + + try: + service = LongTermMemoryService.from_env() + service.save_event_strings( + user_id=user_id, + event_strings=event_strings, + metadata=metadata, + ) + except Exception as exc: + logger.warning("Failed to auto-save conversation turn to long-term memory: %s", exc) + + +def _merge_request_history_with_session_history( + request_history: Sequence[dict[str, str]], + session_history: Sequence[dict[str, str]], +) -> list[dict[str, str]]: + if not request_history: + return list(session_history) + if not session_history: + return list(request_history) + + normalized_request = [ + { + "role": str(item.get("role") or ""), + "content": str(item.get("content") or "").strip(), + } + for item in request_history + ] + normalized_session = [ + { + "role": str(item.get("role") or ""), + "content": str(item.get("content") or "").strip(), + } + for item in session_history + ] + prefix_len = min(len(normalized_request), len(normalized_session)) + if normalized_request[:prefix_len] == normalized_session[:prefix_len]: + return [*list(request_history), *list(session_history)[prefix_len:]] + return [*list(request_history), *list(session_history)] + + +def _merge_responses_history_with_session_history( + request_history: Sequence[dict[str, Any]], + session_history: Sequence[dict[str, Any]], +) -> list[dict[str, Any]]: + """Merge request-provided Responses items with the session projection.""" + if not request_history: + return [dict(item) for item in session_history] + if not session_history: + return [dict(item) for item in request_history] + + prefix_len = min(len(request_history), len(session_history)) + if list(request_history[:prefix_len]) == list(session_history[:prefix_len]): + return [ + *[dict(item) for item in request_history], + *[dict(item) for item in session_history[prefix_len:]], + ] + return [ + *[dict(item) for item in request_history], + *[dict(item) for item in session_history], + ] diff --git a/ksadk/conversations/runtime_invocation.py b/ksadk/conversations/runtime_invocation.py new file mode 100644 index 00000000..ab51ea8d --- /dev/null +++ b/ksadk/conversations/runtime_invocation.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import asyncio +import json +from typing import Any, Callable, Dict, Mapping, Optional, Sequence + +from ksadk.conversations.reasoning_markup import strip_reasoning_markup +from ksadk.conversations.run_kinds import ( + RUN_MODE_FOREGROUND, + trigger_from_resume_input, + validate_run_mode, +) +from ksadk.conversations.runtime_constants import ( + PTL_RETRY_KEEP_TAIL_GROUPS, +) +from ksadk.conversations.runtime_governance import ( + RuntimeCircuitOpen, + _compact_conversation_history_with_governance, + _governance_record_turn_start, + _runtime_governance_from_env, +) +from ksadk.conversations.runtime_input import ( + _auto_save_ltm_turn, + _build_runner_ambient_contexts, + _build_runner_request_payload, + _inject_runner_deferred_tools_for_request, + _is_prompt_too_long_error, + _runner_name, + _runner_type_name, +) +from ksadk.conversations.runtime_metadata import _update_session_metadata_after_assistant_turn +from ksadk.conversations.runtime_observability import ( + _conversation_span_scope, + _normalize_usage_payload, + _set_conversation_input_attributes, + _set_conversation_output_attributes, + _set_conversation_span_attributes, + _set_conversation_usage_attributes, + _span_feedback_metadata, +) +from ksadk.conversations.runtime_persistence import ( + append_conversation_event, + append_run_checkpoint_event, + append_run_status_event, +) +from ksadk.conversations.runtime_preparation import _refresh_history, build_run_input +from ksadk.conversations.runtime_resume import ( + _checkpoint_event_args_from_agentengine_metadata, + _extract_agentengine_metadata, + _failed_status_for_resume, + _merge_agentengine_metadata, +) +from ksadk.model_policy import fallback_model_for_exception, model_policy_options_for_model +from ksadk.runtime_context import ( + PlatformInvocationContext, + platform_invocation_scope, + tool_execution_scope, +) +from ksadk.sessions import resolve_session_service + + +async def invoke_conversation_once( + *, + runner: Any, + agent_id: str, + user_id: str, + session_id: Optional[str], + messages: Sequence[Dict[str, Any]], + model: Optional[str], + prepare_runner: Callable[[Any, Optional[str]], None], + 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, + response_id: str | 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, +) -> tuple[str, dict[str, Any]]: + """非流式 turn 编排入口。 + + 顺序固定为:写用户事件 -> 需要时 compact -> 写 run_status(in_progress) + -> 调 runner -> PTL 时 compact/retry -> 写 assistant 结果 -> 写 completed。 + """ + provider = session_service_provider or resolve_session_service + prepare_runner(runner, model) + governance = _runtime_governance_from_env() + _governance_record_turn_start(governance) + # 入口算 run_trigger(不依赖 prepared,build_run_input 失败时也能用) + entry_run_mode = validate_run_mode(run_mode) + entry_run_trigger = trigger_from_resume_input(resume_input) + try: + 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, + governance_state=governance, + session_service_provider=provider, + run_mode=entry_run_mode, + ) + # prepared 之后的 run_status 写入复用 prepared 的 mode/trigger + run_mode = prepared.run_mode + run_trigger = prepared.run_trigger + except RuntimeCircuitOpen as exc: + if session_id: + await append_run_status_event( + session_id=session_id, + author=_runner_name(runner), + status=_failed_status_for_resume(resume_input), + invocation_id=invocation_id, + detail=str(exc), + metadata={"governance": exc.metadata}, + session_service_provider=provider, + run_mode=entry_run_mode, + run_trigger=entry_run_trigger, + ) + raise + _inject_runner_deferred_tools_for_request(runner, prepared) + ambient_contexts = _build_runner_ambient_contexts( + runner=runner, + user_id=user_id, + user_input=prepared.user_input, + ) + runtime_context = PlatformInvocationContext( + agent_id=agent_id, + user_id=user_id, + account_id=str(account_id or ""), + session_id=prepared.session_id, + history=list(prepared.history), + input_content=list(prepared.input_content), + input_messages=list(prepared.input_messages), + input_parts=list(prepared.user_parts), + attachments=list(prepared.attachments), + attachment_results=list(prepared.attachment_results), + current_attachments=list(prepared.current_attachments), + current_attachment_results=list(prepared.current_attachment_results), + has_current_files=prepared.has_current_files, + runner_type=_runner_type_name(runner), + metadata=dict(custom_metadata or {}), + model=model, + 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 "" + ), + ) + runner_name = _runner_name(runner) + async with _conversation_span_scope(runner_name) as span: + _set_conversation_span_attributes( + span, + agent_id=agent_id, + user_id=user_id, + session_id=prepared.session_id, + invocation_id=prepared.invocation_id, + runner_name=runner_name, + model=model, + response_id=response_id, + ) + _set_conversation_input_attributes(span, prepared.user_input or prepared.user_display_input) + trace_metadata = _span_feedback_metadata(span) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="in_progress", + invocation_id=prepared.invocation_id, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + + result: dict[str, Any] | None = None + last_invoke_error: Exception | None = None + for attempt in range(2): + try: + last_invoke_error = None + runtime_context.history = list(prepared.history) + with platform_invocation_scope(runtime_context): + with tool_execution_scope( + session_id=prepared.session_id, + run_id=prepared.invocation_id, + invocation_id=prepared.invocation_id, + ): + result = await runner.invoke( + _build_runner_request_payload( + prepared=prepared, + model=model, + runtime_context=runtime_context, + runner=runner, + ) + ) + break + except asyncio.CancelledError: + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="cancelled", + invocation_id=prepared.invocation_id, + detail="cancel_requested", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + raise + except Exception as exc: + if attempt == 0 and _is_prompt_too_long_error(exc): + try: + checkpoint = await _compact_conversation_history_with_governance( + governance, + session_id=prepared.session_id, + author=runner_name, + invocation_id=prepared.invocation_id, + model=model, + model_metadata=prepared.model_metadata, + force=True, + trigger="prompt_too_long", + keep_tail_groups=PTL_RETRY_KEEP_TAIL_GROUPS, + session_service_provider=provider, + ) + except RuntimeCircuitOpen as circuit_exc: + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status=_failed_status_for_resume(resume_input), + invocation_id=prepared.invocation_id, + detail=str(circuit_exc), + metadata={"governance": circuit_exc.metadata}, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + raise circuit_exc + if checkpoint: + prepared = await _refresh_history( + prepared, session_service_provider=provider + ) + runtime_context.history = list(prepared.history) + continue + fallback_model = fallback_model_for_exception(exc, current_model=model or "") + if attempt == 0 and fallback_model: + model = fallback_model + runtime_context.model = fallback_model + runtime_context.model_options = { + **prepared.model_options, + **model_policy_options_for_model(fallback_model), + } + prepare_runner(runner, fallback_model) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="in_progress", + invocation_id=prepared.invocation_id, + detail=f"fallback_model:{fallback_model}", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + continue + last_invoke_error = exc + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status=_failed_status_for_resume(resume_input), + invocation_id=prepared.invocation_id, + detail=str(exc), + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + break + + if last_invoke_error is not None: + raise last_invoke_error + result = result or {} + output_text = strip_reasoning_markup(str(result.get("output", ""))) + result_usage = _normalize_usage_payload(result.get("usage")) + result_last_usage = _normalize_usage_payload( + (result.get("metadata") or {}).get("last_usage") + ) or (result_usage if result_usage else {}) + _set_conversation_output_attributes(span, output_text) + _set_conversation_usage_attributes(span, result_usage) + result_agentengine_metadata = _extract_agentengine_metadata(result) + assistant_metadata: dict[str, Any] = { + **trace_metadata, + **_merge_agentengine_metadata(prepared.request_metadata, result_agentengine_metadata), + } + if prepared.request_metadata: + request_metadata_without_agentengine = { + key: value + for key, value in prepared.request_metadata.items() + if key != "agentengine" + } + if request_metadata_without_agentengine: + assistant_metadata["request_metadata"] = request_metadata_without_agentengine + if result_usage: + assistant_metadata["usage"] = result_usage + if result_last_usage: + assistant_metadata["last_usage"] = result_last_usage + if response_id: + assistant_metadata["response_id"] = response_id + checkpoint_args = _checkpoint_event_args_from_agentengine_metadata( + ( + assistant_metadata.get("agentengine") + if isinstance(assistant_metadata, Mapping) + else None + ), + fallback_run_id=prepared.invocation_id, + ) + if checkpoint_args: + await append_run_checkpoint_event( + session_id=prepared.session_id, + author=runner_name, + run_id=checkpoint_args["run_id"], + checkpoint_id=checkpoint_args["checkpoint_id"], + framework=checkpoint_args["framework"], + framework_ref=checkpoint_args["framework_ref"], + phase=checkpoint_args.get("phase") or "completed", + invocation_id=prepared.invocation_id, + metadata=checkpoint_args.get("metadata"), + session_service_provider=provider, + ) + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model", + text=output_text, + invocation_id=prepared.invocation_id, + event_type="assistant_message", + metadata=assistant_metadata or None, + session_service_provider=provider, + ) + await _update_session_metadata_after_assistant_turn( + service=provider(), + session_id=prepared.session_id, + assistant_text=output_text, + model=model, + ) + await _auto_save_ltm_turn( + agent_id=agent_id, + user_id=user_id, + prepared=prepared, + output_text=output_text, + runner_type=runtime_context.runner_type, + model=model, + ) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="completed", + invocation_id=prepared.invocation_id, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + result_payload: dict[str, Any] = { + "output_text": output_text, + "model": model, + "metadata": { + **trace_metadata, + **{ + key: value + for key, value in prepared.request_metadata.items() + if key != "agentengine" + }, + **_merge_agentengine_metadata( + prepared.request_metadata, result_agentengine_metadata + ), + }, + } + if result_usage: + result_payload["usage"] = result_usage + result_payload["metadata"]["usage"] = result_usage + if result_last_usage: + result_payload["metadata"]["last_usage"] = result_last_usage + if response_id: + result_payload["response_id"] = response_id + return prepared.session_id, result_payload + + +def _response_sse(event: str, data: Mapping[str, Any]) -> str: + return f"event: {event}\ndata: {json.dumps(dict(data), ensure_ascii=False)}\n\n" diff --git a/ksadk/conversations/runtime_metadata.py b/ksadk/conversations/runtime_metadata.py new file mode 100644 index 00000000..df61fad5 --- /dev/null +++ b/ksadk/conversations/runtime_metadata.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from typing import Any, Dict, Mapping, Optional, Sequence + +import httpx + +from ksadk.conversations.attachments import compact_attachment_result_for_session +from ksadk.conversations.context import ( + canonical_event_type, +) +from ksadk.conversations.model_context import ( + normalize_model_metadata, +) +from ksadk.conversations.normalize import ( + canonical_input_content_from_parts, + compact_attachment_for_session, + normalize_kop_messages, +) +from ksadk.conversations.reasoning_markup import strip_reasoning_markup +from ksadk.conversations.runtime_constants import ( + _MODEL_CATALOG_CACHE, + _MODEL_CATALOG_CACHE_TTL_SECONDS, + ATTACHMENT_CONTEXT_STATE_KEY, + SESSION_SUMMARY_MAX_CHARS, + logger, +) +from ksadk.conversations.session_title import ( + DEFAULT_SESSION_TITLE_TIMEOUT_MS, + HEURISTIC_SESSION_TITLE_SOURCE, + build_fallback_title, + build_heuristic_title, + build_session_title_messages, + is_low_quality_title, + resolve_session_title_client, + resolve_session_title_model, +) +from ksadk.sessions import Session, SessionEvent + + +def _truncate_text(text: str | None, limit: int) -> str: + raw = " ".join(str(text or "").strip().split()) + if len(raw) <= limit: + return raw + return f"{raw[: max(limit - 1, 0)].rstrip()}…" + + +async def _update_session_metadata_after_user_turn( + *, + service: Any, + session: Session, + user_input: str, +) -> None: + text = _truncate_text(user_input, SESSION_SUMMARY_MAX_CHARS) + if not text: + return + updates: dict[str, str] = {"last_prompt": text} + if not (session.first_prompt or "").strip(): + updates["first_prompt"] = text + if not (session.title or "").strip(): + updates["title"] = build_fallback_title(session.first_prompt or text) + updates["title_source"] = "fallback_first_prompt" + await service.update_session_metadata(session.id, **updates) + + +async def prime_session_metadata_for_user_turn( + *, + service: Any, + session: Session, + messages: Sequence[Mapping[str, Any]] | None = None, + user_input: str | None = None, +) -> None: + text = str(user_input or "").strip() + if not text and messages: + text, _display, _content, _parts, _attachments, _attachment_results = _latest_user_turn( + messages + ) + await _update_session_metadata_after_user_turn( + service=service, + session=session, + user_input=text, + ) + + +async def _update_session_metadata_after_assistant_turn( + *, + service: Any, + session_id: str, + assistant_text: str, + model: str | None, +) -> None: + summary = _truncate_text(strip_reasoning_markup(assistant_text), SESSION_SUMMARY_MAX_CHARS) + if summary: + await service.update_session_metadata(session_id, summary=summary) + + session = await service.get_session(session_id) + if not session: + return + if (session.title_source or "").strip() != "fallback_first_prompt": + return + first_prompt = str(session.first_prompt or "").strip() + if not first_prompt or not summary: + return + + next_title = build_heuristic_title(first_prompt=first_prompt, assistant_text=summary) + next_title_source = ( + HEURISTIC_SESSION_TITLE_SOURCE + if next_title and next_title != (session.title or "").strip() + else "" + ) + if next_title and next_title != (session.title or "").strip(): + await service.update_session_metadata( + session_id, + title=next_title, + title_source=next_title_source, + ) + + title_client = resolve_session_title_client() + title_model = resolve_session_title_model(model) + if title_client.is_available and title_model: + asyncio.create_task( + _refine_session_title_in_background( + service=service, + session_id=session_id, + first_prompt=first_prompt, + assistant_text=summary, + model=title_model, + ) + ) + + +async def _refine_session_title_in_background( + *, + service: Any, + session_id: str, + first_prompt: str, + assistant_text: str, + model: str, +) -> None: + title_client = resolve_session_title_client() + try: + title, _usage = await title_client.generate_title( + model=model, + messages=build_session_title_messages( + first_prompt=first_prompt, + assistant_text=assistant_text, + ), + timeout_ms=DEFAULT_SESSION_TITLE_TIMEOUT_MS, + ) + except Exception: + logger.debug("failed to refine session title", exc_info=True) + return + + if not title or is_low_quality_title(title, first_prompt=first_prompt): + return + session = await service.get_session(session_id) + if not session: + return + if title == (session.title or "").strip(): + return + await service.update_session_metadata( + session_id, + title=title, + title_source="ai", + ) + + +def _resolve_model_metadata( + model: Optional[str], + *, + model_metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """统一收口模型上下文配置。 + + 当前阶段还没把远端 /v1/models 的完整 metadata 缓存接进 runtime, + 所以这里只用默认值 + model id。后续模型目录接口上线 richer metadata + 后,只需要把这层改成真正的 resolver,compaction 逻辑本身不用再动。 + """ + + if isinstance(model_metadata, Mapping): + resolved = dict(model_metadata) + if model and not str(resolved.get("id") or "").strip(): + resolved["id"] = model + return dict(normalize_model_metadata(resolved)) + return dict(normalize_model_metadata({"id": model or "agent"})) + + +def _model_catalog_endpoint(api_base: str) -> str: + base_url = str(api_base or "").rstrip("/") + if not base_url: + return "" + if base_url.endswith("/v1"): + return f"{base_url}/models" + return f"{base_url}/v1/models" + + +async def _fetch_remote_model_catalog(api_base: str, api_key: str) -> list[dict[str, Any]]: + url = _model_catalog_endpoint(api_base) + if not url: + return [] + + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async with httpx.AsyncClient(verify=False, timeout=10) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + + raw_models = payload if isinstance(payload, list) else list(payload.get("data", [])) + normalized: list[dict[str, Any]] = [] + for item in raw_models: + if isinstance(item, Mapping) or isinstance(item, str): + normalized.append(normalize_model_metadata(item)) + return normalized + + +async def _resolve_runtime_model_metadata( + model: Optional[str], + *, + model_metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + resolved = _resolve_model_metadata(model, model_metadata=model_metadata) + if isinstance(model_metadata, Mapping) or not model: + return resolved + + api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") or "" + if not api_base: + return resolved + + api_key = os.getenv("OPENAI_API_KEY", "") + cache_key = (api_base.rstrip("/"), api_key) + now = time.monotonic() + cached = _MODEL_CATALOG_CACHE.get(cache_key) + models: list[dict[str, Any]] + if cached and (now - cached[0]) < _MODEL_CATALOG_CACHE_TTL_SECONDS: + models = cached[1] + else: + try: + models = await _fetch_remote_model_catalog(api_base, api_key) + _MODEL_CATALOG_CACHE[cache_key] = (now, models) + except Exception as exc: + logger.debug("Failed to fetch remote model metadata for %s: %s", model, exc) + return resolved + + target = str(model).strip() + for item in models: + if str(item.get("id") or "").strip() == target: + return item + return resolved + + +def _normalized_conversation_messages(messages: Sequence[Dict[str, Any]]) -> list[dict[str, Any]]: + """把不同入口的 message 形态收敛成统一内部格式。""" + + normalized_messages: list[dict[str, Any]] = [] + for message in list(messages or []): + if isinstance(message, dict) and any( + key in message + for key in ("display_content", "attachments", "attachment_results", "parts") + ): + normalized_messages.append( + { + "role": str(message.get("role") or "user"), + "content": str(message.get("content") or ""), + "display_content": str( + message.get("display_content") or message.get("content") or "" + ), + "parts": list(message.get("parts") or []), + "input_content": list( + message.get("input_content") + or canonical_input_content_from_parts(list(message.get("parts") or [])) + ), + "attachments": list(message.get("attachments") or []), + "attachment_results": list(message.get("attachment_results") or []), + } + ) + continue + normalized_messages.extend(normalize_kop_messages([message])) + return normalized_messages + + +def _latest_user_turn( + normalized_messages: Sequence[Mapping[str, Any]], +) -> tuple[ + str, + str, + list[dict[str, Any]], + list[dict[str, Any]], + list[dict[str, Any]], + list[dict[str, Any]], +]: + latest_user_message = next( + (message for message in reversed(normalized_messages) if message.get("role") == "user"), + {}, + ) + user_input = str(latest_user_message.get("content") or "") + user_display_input = str(latest_user_message.get("display_content") or user_input) + input_content = list(latest_user_message.get("input_content") or []) + user_parts = list(latest_user_message.get("parts") or []) + attachments = list(latest_user_message.get("attachments") or []) + attachment_results = list(latest_user_message.get("attachment_results") or []) + return ( + user_input, + user_display_input, + input_content, + user_parts, + attachments, + attachment_results, + ) + + +def _canonical_input_messages( + normalized_messages: Sequence[Dict[str, Any]], +) -> list[dict[str, Any]]: + input_messages: list[dict[str, Any]] = [] + for message in normalized_messages or []: + role = str(message.get("role") or "user") + content = list(message.get("input_content") or []) + if not content: + text = str(message.get("content") or "") + if text: + content = [{"type": "input_text", "text": text}] + input_messages.append({"role": role, "content": content}) + return input_messages + + +def _parts_include_file(parts: Sequence[dict[str, Any]]) -> bool: + for part in parts or []: + if not isinstance(part, dict): + continue + if part.get("inlineData") is not None or part.get("fileData") is not None: + return True + return False + + +def _latest_attachment_context_from_messages( + normalized_messages: Sequence[Dict[str, Any]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + attachments: list[dict[str, Any]] = [] + attachment_results: list[dict[str, Any]] = [] + for message in normalized_messages: + if str(message.get("role") or "user") != "user": + continue + message_attachments = list(message.get("attachments") or []) + message_attachment_results = list(message.get("attachment_results") or []) + if message_attachments or message_attachment_results: + attachments = message_attachments + attachment_results = message_attachment_results + return attachments, attachment_results + + +def _attachment_context_from_session( + session: Session | None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + state = getattr(session, "state", None) or {} + payload = state.get(ATTACHMENT_CONTEXT_STATE_KEY) + if not isinstance(payload, dict): + return [], [] + return ( + [ + compact_attachment_for_session(item) + for item in payload.get("attachments") or [] + if isinstance(item, dict) + ], + [ + compact_attachment_result_for_session(item) + for item in payload.get("attachment_results") or [] + if isinstance(item, dict) + ], + ) + + +def _build_attachment_context_state_delta( + *, + base_state_delta: dict[str, Any] | None, + attachments: Sequence[dict[str, Any]], + attachment_results: Sequence[dict[str, Any]], +) -> dict[str, Any]: + merged = dict(base_state_delta or {}) + if attachments or attachment_results: + merged[ATTACHMENT_CONTEXT_STATE_KEY] = { + "attachments": [ + compact_attachment_for_session(item) + for item in attachments + if isinstance(item, dict) + ], + "attachment_results": [ + compact_attachment_result_for_session(item) + for item in attachment_results + if isinstance(item, dict) + ], + } + return merged + + +def _resolve_effective_attachment_context( + *, + normalized_messages: Sequence[Dict[str, Any]], + session: Session | None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + message_attachments, message_attachment_results = _latest_attachment_context_from_messages( + normalized_messages + ) + if message_attachments or message_attachment_results: + return message_attachments, message_attachment_results + + session_attachments, session_attachment_results = _attachment_context_from_session(session) + return session_attachments, session_attachment_results + + +def _transcript_event_type(event: SessionEvent) -> str: + return str( + canonical_event_type( + event.event_type, + author=event.author, + role=str((event.content or {}).get("role") or ""), + ) + ) + + +def _build_pending_user_event( + *, + session_id: str, + invocation_id: str, + user_input: str, + user_display_input: str, + attachments: Sequence[dict[str, Any]], + attachment_results: Sequence[dict[str, Any]], +) -> SessionEvent: + """构造一条未落库的用户事件,专供 compaction 预览使用。""" + + return SessionEvent.from_dict( + { + "id": f"preview-{uuid.uuid4()}", + "author": "user", + "event_type": "user_message", + "invocationId": invocation_id, + "content": {"role": "user", "parts": [{"text": user_display_input or user_input}]}, + "timestamp": int(time.time() * 1000), + "metadata": { + "agent_input": user_input, + "attachments": [ + compact_attachment_for_session(item) for item in attachments if item + ], + "attachment_results": [ + compact_attachment_result_for_session(item) + for item in attachment_results + if item + ], + }, + "stateDelta": {}, + }, + session_id=session_id, + ) + + +def _user_event_content( + *, + user_input: str, + user_display_input: str, + input_content: Sequence[dict[str, Any]], + user_parts: Sequence[dict[str, Any]], +) -> dict[str, Any]: + parts = list(input_content or []) + if not parts: + parts = canonical_input_content_from_parts(list(user_parts or [])) + if not parts: + text = user_display_input or user_input + parts = [{"text": text}] if text else [] + return {"role": "user", "parts": parts} diff --git a/ksadk/conversations/runtime_observability.py b/ksadk/conversations/runtime_observability.py new file mode 100644 index 00000000..bccc4a72 --- /dev/null +++ b/ksadk/conversations/runtime_observability.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager, nullcontext +from typing import Any, Mapping, Sequence + +from ksadk.sessions import SessionEvent + + +def _extract_deferred_tool_names(output: Any) -> list[str]: + if not isinstance(output, Mapping): + return [] + raw_names = output.get("deferred_tool_names") + if not isinstance(raw_names, Sequence) or isinstance(raw_names, (str, bytes, bytearray)): + return [] + names: list[str] = [] + seen: set[str] = set() + for item in raw_names: + name = str(item or "").strip() + if not name or name in seen: + continue + seen.add(name) + names.append(name) + return names + + +def _latest_deferred_tool_names(events: Sequence[SessionEvent]) -> list[str]: + for event in reversed(events): + if event.event_type != "run_status": + continue + metadata = event.metadata or {} + if metadata.get("detail") != "deferred_tools_selected": + continue + return _extract_deferred_tool_names(metadata) + return [] + + +def _normalize_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any]: + if not isinstance(usage, Mapping): + return {} + normalized: dict[str, Any] = {} + for key in ( + "input_tokens", + "output_tokens", + "total_tokens", + "prompt_tokens", + "completion_tokens", + ): + value = usage.get(key) + if value is None: + continue + try: + normalized[key] = int(value) + except (TypeError, ValueError): + continue + for key in ("input_token_details", "output_token_details"): + value = usage.get(key) + if isinstance(value, Mapping): + normalized[key] = dict(value) + prompt_details = usage.get("prompt_tokens_details") + if isinstance(prompt_details, Mapping): + normalized["prompt_tokens_details"] = dict(prompt_details) + cached_tokens = prompt_details.get("cached_tokens") + if cached_tokens is not None: + try: + normalized.setdefault("input_token_details", {})["cached"] = int(cached_tokens) + except (TypeError, ValueError): + pass + completion_details = usage.get("completion_tokens_details") + if isinstance(completion_details, Mapping): + normalized["completion_tokens_details"] = dict(completion_details) + reasoning_tokens = completion_details.get("reasoning_tokens") + if reasoning_tokens is not None: + try: + normalized.setdefault("output_token_details", {})["reasoning"] = int( + reasoning_tokens + ) + except (TypeError, ValueError): + pass + return normalized + + +def _usage_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any]: + if not isinstance(metadata, Mapping): + return {} + return _normalize_usage_payload(metadata.get("usage")) + + +def _responses_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | None: + normalized = _normalize_usage_payload(usage) + if not normalized: + return None + input_tokens = normalized.get("input_tokens", normalized.get("prompt_tokens", 0)) + output_tokens = normalized.get("output_tokens", normalized.get("completion_tokens", 0)) + total_tokens = normalized.get("total_tokens", input_tokens + output_tokens) + payload = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "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"]) + return payload + + +def _chat_usage_payload(usage: Mapping[str, Any] | None) -> dict[str, Any] | None: + normalized = _normalize_usage_payload(usage) + if not normalized: + return None + prompt_tokens = normalized.get("prompt_tokens", normalized.get("input_tokens", 0)) + completion_tokens = normalized.get("completion_tokens", normalized.get("output_tokens", 0)) + total_tokens = normalized.get("total_tokens", prompt_tokens + completion_tokens) + payload = { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + } + prompt_details = normalized.get("prompt_tokens_details") + if isinstance(prompt_details, Mapping): + payload["prompt_tokens_details"] = dict(prompt_details) + input_token_details = normalized.get("input_token_details") + if isinstance(input_token_details, Mapping) and input_token_details: + prompt_details = dict(payload.get("prompt_tokens_details") or {}) + cached_tokens = input_token_details.get("cached") + if cached_tokens is not None: + try: + prompt_details["cached_tokens"] = int(cached_tokens) + except (TypeError, ValueError): + pass + if prompt_details: + payload["prompt_tokens_details"] = prompt_details + completion_details = normalized.get("completion_tokens_details") + if isinstance(completion_details, Mapping): + payload["completion_tokens_details"] = dict(completion_details) + output_token_details = normalized.get("output_token_details") + if isinstance(output_token_details, Mapping) and output_token_details: + completion_details = dict(payload.get("completion_tokens_details") or {}) + reasoning_tokens = output_token_details.get("reasoning") + if reasoning_tokens is not None: + try: + completion_details["reasoning_tokens"] = int(reasoning_tokens) + except (TypeError, ValueError): + pass + if completion_details: + payload["completion_tokens_details"] = completion_details + return payload + + +def _get_conversation_tracer() -> Any | None: + try: + from opentelemetry import trace + + return trace.get_tracer("ksadk.conversations") + except Exception: + return None + + +def _current_span_feedback_metadata() -> dict[str, str]: + try: + from opentelemetry import trace + + span = trace.get_current_span() + except Exception: + return {} + return _span_feedback_metadata(span) + + +def _span_feedback_metadata(span: Any | None) -> dict[str, str]: + if span is None: + return {} + try: + context = span.get_span_context() + except Exception: + return {} + if not getattr(context, "is_valid", False): + return {} + return { + "trace_id": format(context.trace_id, "032x"), + "root_span_id": format(context.span_id, "016x"), + } + + +def _get_current_span() -> Any | None: + try: + from opentelemetry import trace + + return trace.get_current_span() + except Exception: + return None + + +def _span_current_context(span: Any | None): + if span is None: + return nullcontext() + try: + from opentelemetry.trace import use_span + + return use_span( + span, end_on_exit=False, record_exception=False, set_status_on_exception=False + ) + except Exception: + return nullcontext() + + +@asynccontextmanager +async def _conversation_span_scope(name: str, *, manual_end: bool = False): + tracer = _get_conversation_tracer() + if tracer is None: + yield None + return + if manual_end: + span = tracer.start_span(name) + try: + yield span + finally: + try: + span.end() + except Exception: + pass + return + span = tracer.start_span(name) + try: + yield span + finally: + try: + span.end() + except Exception: + pass + + +def _set_span_attribute(span: Any | None, key: str, value: Any) -> None: + if span is None: + return + if value is None: + return + if isinstance(value, str): + value = value.strip() + if not value: + return + try: + span.set_attribute(key, value) + except Exception: + return + + +def _set_conversation_input_attributes(span: Any | None, input_text: str | None) -> None: + text = " ".join(str(input_text or "").split()) + if not text: + return + for key in ( + "langfuse.trace.input", + "langfuse.observation.input", + "input.value", + "gen_ai.prompt", + ): + _set_span_attribute(span, key, text) + + +def _set_conversation_output_attributes(span: Any | None, output_text: str | None) -> None: + text = " ".join(str(output_text or "").split()) + if not text: + return + for key in ( + "langfuse.trace.output", + "langfuse.observation.output", + "output.value", + "gen_ai.completion", + ): + _set_span_attribute(span, key, text) + + +def _set_conversation_usage_attributes( + span: Any | None, + usage: Mapping[str, Any] | None, +) -> None: + normalized = _normalize_usage_payload(usage) + if not normalized: + return + + def _usage_int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + input_tokens = _usage_int(normalized.get("input_tokens")) + output_tokens = _usage_int(normalized.get("output_tokens")) + total_tokens = _usage_int(normalized.get("total_tokens") or (input_tokens + output_tokens)) + input_details = normalized.get("input_token_details") + output_details = normalized.get("output_token_details") + cache_read_tokens = 0 + reasoning_tokens = 0 + if isinstance(input_details, Mapping): + cache_read_tokens = _usage_int( + input_details.get("cache_read") + or input_details.get("cached") + or input_details.get("cached_tokens") + ) + if isinstance(output_details, Mapping): + reasoning_tokens = _usage_int( + output_details.get("reasoning") or output_details.get("reasoning_tokens") + ) + + attributes = { + "gen_ai.usage.input_tokens": input_tokens, + "gen_ai.usage.output_tokens": output_tokens, + "gen_ai.usage.total_tokens": total_tokens, + "llm.usage.prompt_tokens": input_tokens, + "llm.usage.completion_tokens": output_tokens, + "llm.usage.total_tokens": total_tokens, + } + if cache_read_tokens: + attributes["gen_ai.usage.cache_read.input_tokens"] = cache_read_tokens + attributes["llm.usage.cache_read.input_tokens"] = cache_read_tokens + if reasoning_tokens: + attributes["gen_ai.usage.reasoning.output_tokens"] = reasoning_tokens + attributes["llm.usage.reasoning_tokens"] = reasoning_tokens + + for key, value in attributes.items(): + if value: + _set_span_attribute(span, key, value) + + +def _set_conversation_span_attributes( + span: Any, + *, + agent_id: str, + user_id: str, + session_id: str, + invocation_id: str, + runner_name: str, + model: str | None, + response_id: str | None = None, +) -> None: + if span is None: + return + try: + span.set_attribute("ksadk.agent_id", agent_id) + span.set_attribute("ksadk.user_id", user_id) + span.set_attribute("ksadk.session_id", session_id) + span.set_attribute("ksadk.invocation_id", invocation_id) + span.set_attribute("ksadk.runner", runner_name) + span.set_attribute("langfuse.trace.name", runner_name) + span.set_attribute("langfuse.session.id", session_id) + span.set_attribute("session.id", session_id) + span.set_attribute("langfuse.user.id", user_id) + span.set_attribute("user.id", user_id) + if model: + span.set_attribute("llm.model_name", model) + span.set_attribute("gen_ai.request.model", model) + if response_id: + span.set_attribute("ksadk.response_id", response_id) + except Exception: + return diff --git a/ksadk/conversations/runtime_payloads.py b/ksadk/conversations/runtime_payloads.py new file mode 100644 index 00000000..b262503b --- /dev/null +++ b/ksadk/conversations/runtime_payloads.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import json +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional, Sequence + +from ksadk.conversations.run_kinds import ( + RUN_MODE_FOREGROUND, + RUN_TRIGGER_NEW_RUN, +) +from ksadk.conversations.runtime_observability import ( + _chat_usage_payload, + _responses_usage_payload, + _usage_from_metadata, +) +from ksadk.sessions import SessionEvent + + +@dataclass +class PreparedConversationTurn: + """一次 turn 编排后的标准输入。 + + 这个对象把“会话归属”“用户最新输入”“投影后的上下文 history” + 和“附件/parts”等运行时所需信息收拢到一起,避免不同 endpoint + 各自重新拼装。 + """ + + session_id: str + invocation_id: str + user_input: str + user_display_input: str + history: list[dict[str, str]] + input_content: list[dict[str, Any]] + input_messages: list[dict[str, Any]] + user_parts: list[dict[str, Any]] + attachments: list[dict[str, Any]] + attachment_results: list[dict[str, Any]] + current_attachments: list[dict[str, Any]] + current_attachment_results: list[dict[str, Any]] + has_current_files: bool + model_metadata: dict[str, Any] = field(default_factory=dict) + model_options: dict[str, Any] = field(default_factory=dict) + instructions: str = "" + request_metadata: dict[str, Any] = field(default_factory=dict) + compaction_triggered: bool = False + compaction_trigger: str | None = None + compacted_until_seq_id: int | None = None + resume_input: dict[str, Any] | None = None + # 双维度 run 标识:run_mode=怎么跑(background/foreground), + # run_trigger=怎么开始(new_run/checkpoint_resume/approval_resume) + run_mode: str = RUN_MODE_FOREGROUND + run_trigger: str = RUN_TRIGGER_NEW_RUN + request_history: list[dict[str, str]] = field(default_factory=list) + request_responses_history: list[dict[str, Any]] = field(default_factory=list) + responses_history: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class CompactionPlan: + """一次 compaction 规划结果。 + + 预览阶段和真正落 checkpoint 阶段都复用这份规划,避免 `/run_sse` + 与 conversation runtime 各自写一套“是否需要压缩”的条件判断。 + """ + + should_compact: bool + groups_to_compact: list[list[SessionEvent]] + total_chars: int + total_estimated_tokens: int + group_count: int + tail_groups: int + auto_compact_threshold_tokens: int | None = None + auto_compact_threshold_percentage: int | None = None + compacted_until_seq_id: int | None = None + pinned_group_indexes: list[int] = field(default_factory=list) + pinned_state: dict[str, Any] = field(default_factory=dict) + + +def build_responses_payload( + *, + output_text: str, + model: Optional[str], + session_id: str, + response_id: str | None = None, + created_at: int | None = None, + status: str = "completed", + metadata: Mapping[str, Any] | None = None, + usage: Mapping[str, Any] | None = None, + incomplete_details: Mapping[str, Any] | None = None, + error: Mapping[str, Any] | None = None, + output_items: Sequence[Mapping[str, Any]] | None = None, +) -> dict[str, Any]: + response_id = response_id or f"resp_{uuid.uuid4().hex}" + created_at = created_at or int(time.time()) + message_id = f"msg_{uuid.uuid4().hex[:12]}" + output_item_status = "completed" if status == "completed" else status + message_item = { + "id": message_id, + "type": "message", + "status": output_item_status, + "role": "assistant", + "content": [{"type": "output_text", "text": output_text}], + } + normalized_output_items = [ + dict(item) for item in list(output_items or []) if isinstance(item, Mapping) + ] + if normalized_output_items: + output = normalized_output_items + if not any(str(item.get("type") or "") == "message" for item in output): + output = [message_item, *output] + else: + output = [message_item] + usage_payload = _responses_usage_payload(usage or _usage_from_metadata(metadata)) + return { + "id": response_id, + "object": "response", + "created_at": created_at, + "status": status, + "error": dict(error) if isinstance(error, Mapping) else None, + "incomplete_details": ( + dict(incomplete_details) if isinstance(incomplete_details, Mapping) else None + ), + "instructions": None, + "metadata": dict(metadata or {}), + "model": model or "agent", + "parallel_tool_calls": True, + "temperature": None, + "top_p": None, + "tools": [], + "output": output, + "output_text": output_text, + "usage": usage_payload, + "session_id": session_id, + } + + +def extract_responses_resume_input(input_payload: Any) -> dict[str, Any] | None: + """Extract OpenAI Responses approval resume input without exposing runner details.""" + if isinstance(input_payload, Mapping): + candidates = [input_payload] + elif isinstance(input_payload, Sequence) and not isinstance( + input_payload, (str, bytes, bytearray) + ): + candidates = [item for item in input_payload if isinstance(item, Mapping)] + else: + return None + + for item in candidates: + item_type = str(item.get("type") or "").strip() + if item_type == "agentengine.resume_checkpoint": + checkpoint_resume: dict[str, Any] = {"type": "agentengine.resume_checkpoint"} + for key in ( + "run_id", + "checkpoint_id", + "resume_attempt_id", + "framework", + "framework_ref", + ): + if key in item: + checkpoint_resume[key] = item.get(key) + return checkpoint_resume + + if item_type == "mcp_approval_response": + approval_resume: dict[str, Any] = {"type": "mcp_approval_response"} + if item.get("id"): + approval_resume["id"] = str(item.get("id")) + approval_request_id = item.get("approval_request_id") + if approval_request_id: + approval_resume["approval_request_id"] = str(approval_request_id) + if "approve" in item: + approval_resume["approve"] = item.get("approve") + elif "approved" in item: + approval_resume["approve"] = item.get("approved") + if item.get("reason") is not None: + approval_resume["reason"] = str(item.get("reason") or "") + return approval_resume + + if item_type == "function_call_output": + call_id = item.get("call_id") + if not call_id: + continue + function_resume: dict[str, Any] = { + "type": "function_call_output", + "call_id": str(call_id), + "output": item.get("output", ""), + } + if item.get("id"): + function_resume["id"] = str(item.get("id")) + return function_resume + + if item_type in {"ksadk_resume", "ksadk.approval_response"}: + ksadk_resume: dict[str, Any] = {"type": "ksadk_resume"} + interrupt_id = ( + item.get("interrupt_id") or item.get("approval_request_id") or item.get("id") + ) + if interrupt_id: + ksadk_resume["interrupt_id"] = str(interrupt_id) + if "value" in item: + ksadk_resume["value"] = item.get("value") + elif "resume" in item: + ksadk_resume["value"] = item.get("resume") + else: + ksadk_resume["value"] = { + key: value + for key, value in item.items() + if key not in {"type", "interrupt_id", "approval_request_id", "id"} + } + return ksadk_resume + + return None + + +def build_chat_completions_payload( + *, + output_text: str, + model: Optional[str], + session_id: str, + metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + usage = _chat_usage_payload(_usage_from_metadata(metadata)) + payload = { + "id": f"chatcmpl-{uuid.uuid4()}", + "object": "chat.completion", + "created": int(time.time()), + "model": model or "agent", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": output_text}, + "finish_reason": "stop", + } + ], + "usage": usage, + "session_id": session_id, + } + if isinstance(metadata, Mapping) and metadata: + payload["metadata"] = dict(metadata) + return payload + + +def build_compaction_sse_event( + *, + phase: str, + trigger: str, + compacted_until_seq_id: int | None = None, + total_chars: int | None = None, + total_estimated_tokens: int | None = None, + group_count: int | None = None, + threshold_percentage: int | None = None, +) -> str: + """统一生成 compaction 相关 SSE,方便不同入口保持同一语义。""" + + payload: dict[str, Any] = { + "phase": phase, + "trigger": trigger, + "timestamp": int(time.time() * 1000), + } + if compacted_until_seq_id is not None: + payload["compacted_until_seq_id"] = compacted_until_seq_id + if total_chars is not None: + payload["total_chars"] = total_chars + if total_estimated_tokens is not None: + payload["total_estimated_tokens"] = total_estimated_tokens + if group_count is not None: + payload["group_count"] = group_count + if threshold_percentage is not None: + payload["threshold_percentage"] = threshold_percentage + return ( + f"event: response.compaction.{phase}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + ) diff --git a/ksadk/conversations/runtime_persistence.py b/ksadk/conversations/runtime_persistence.py new file mode 100644 index 00000000..34b61441 --- /dev/null +++ b/ksadk/conversations/runtime_persistence.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +import time +import uuid +from typing import Any, Callable, Mapping, Optional, Sequence, cast + +from fastapi import HTTPException + +from ksadk.conversations.context import ( + canonical_event_type, +) +from ksadk.conversations.run_kinds import ( + RUN_MODE_UNKNOWN, + RUN_TRIGGER_UNKNOWN, + validate_run_mode, + validate_run_trigger, +) +from ksadk.conversations.runtime_constants import ( + EVENT_SCAN_PAGE_SIZE, +) +from ksadk.conversations.runtime_observability import _extract_deferred_tool_names +from ksadk.sessions import Session, SessionEvent, resolve_session_service + + +async def ensure_conversation_session( + *, + agent_id: str, + user_id: str, + session_id: Optional[str], + session_service_provider: Callable[[], Any] | None = None, +) -> Session: + """确保会话存在,并在显式 session_id 冲突时做 owner 校验。""" + service = (session_service_provider or resolve_session_service)() + if session_id: + existing = await service.get_session(session_id) + if existing: + if existing.agent_id != agent_id or existing.user_id != user_id: + raise HTTPException( + status_code=409, + detail="Session id belongs to a different agent or user", + ) + return existing + return await service.create_session(agent_id, user_id, session_id=session_id) + return await service.create_session(agent_id, user_id) + + +async def append_conversation_event( + *, + session_id: str, + author: str, + role: str, + text: str, + invocation_id: Optional[str] = None, + state_delta: Optional[dict[str, Any]] = None, + metadata: Optional[dict[str, Any]] = None, + event_type: Optional[str] = None, + content: Optional[dict[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent: + """统一的 canonical event 追加入口。 + + 所有协议层最终都应该落到这里,而不是各自直接 new `SessionEvent`, + 这样 event_type / invocation_id / metadata 的语义才不会再漂移。 + """ + service = (session_service_provider or resolve_session_service)() + payload_content = content if content is not None else {"role": role, "parts": [{"text": text}]} + return await service.append_event( + session_id, + SessionEvent.from_dict( + { + "id": str(uuid.uuid4()), + "author": author, + "event_type": event_type or canonical_event_type(None, author=author, role=role), + "invocationId": invocation_id, + "content": payload_content, + "timestamp": int(time.time() * 1000), + "stateDelta": state_delta or {}, + "metadata": metadata or {}, + }, + session_id=session_id, + ), + ) + + +async def _find_latest_session_event( + service: Any, + session_id: str, + predicate: Callable[[SessionEvent], bool], +) -> SessionEvent | None: + total = await service.count_events(session_id) + offset = 0 + while offset < total: + page = await service.get_events( + session_id, + offset=offset, + limit=min(EVENT_SCAN_PAGE_SIZE, total - offset), + ) + if not page: + return None + for event in reversed(page): + if predicate(event): + return cast(SessionEvent, event) + offset += len(page) + return None + + +async def append_run_status_event( + *, + session_id: str, + author: str, + status: str, + invocation_id: Optional[str] = None, + detail: str | None = None, + metadata: Mapping[str, Any] | None = None, + session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_UNKNOWN, + run_trigger: str = RUN_TRIGGER_UNKNOWN, +) -> SessionEvent: + """记录运行态事件,供 UI/恢复逻辑区分 turn 生命周期。 + + run_mode/run_trigger 是双维度字段(怎么跑/怎么开始),写入 metadata 与 + state_delta.active_run,供前端区分后台长任务、checkpoint 恢复、approval 续跑。 + """ + run_mode = validate_run_mode(run_mode) + run_trigger = validate_run_trigger(run_trigger) + service = (session_service_provider or resolve_session_service)() + if invocation_id: + try: + existing = await _find_latest_session_event( + service, + session_id, + lambda event: ( + event.event_type == "run_status" + and event.invocation_id == invocation_id + and str( + (event.metadata or {}).get("status") + or (event.content or {}).get("status") + or "" + ) + == status + ), + ) + if existing is not None: + return existing + except Exception: + pass + content = {"status": status} + if detail: + content["detail"] = detail + event_metadata = { + "status": status, + **({"detail": detail} if detail else {}), + "run_mode": run_mode, + "run_trigger": run_trigger, + **dict(metadata or {}), + } + # state_delta.active_run:与 agentengine-server _append_run_status 对齐, + # 让 session.state.active_run 反映当前 run 状态(postgres/local backend 会自动合并)。 + # server 侧 ActiveRunStatus 来源是 state_delta 而非扫事件,不写则 server 在 resume + # 期间仍持旧 active_run 值。run_mode/run_trigger 同步写入,供 _serialize_session 读取。 + state_delta = { + "active_run": { + "invocation_id": invocation_id or "", + "status": status, + "run_mode": run_mode, + "run_trigger": run_trigger, + } + } + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="", + invocation_id=invocation_id, + event_type="run_status", + content=content, + metadata=event_metadata, + state_delta=state_delta, + session_service_provider=lambda: service, + ) + + +async def append_deferred_tools_event( + *, + session_id: str, + author: str, + deferred_tool_names: Sequence[str], + invocation_id: Optional[str] = None, + source_tool_name: str = "tool_search", + session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_UNKNOWN, + run_trigger: str = RUN_TRIGGER_UNKNOWN, +) -> SessionEvent | None: + names = _extract_deferred_tool_names({"deferred_tool_names": list(deferred_tool_names)}) + if not names: + return None + run_mode = validate_run_mode(run_mode) + run_trigger = validate_run_trigger(run_trigger) + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="", + invocation_id=invocation_id, + event_type="run_status", + content={"status": "in_progress", "detail": "deferred_tools_selected"}, + metadata={ + "status": "in_progress", + "detail": "deferred_tools_selected", + "run_mode": run_mode, + "run_trigger": run_trigger, + "source_tool_name": source_tool_name, + "deferred_tool_names": names, + }, + state_delta={ + "active_run": { + "invocation_id": invocation_id or "", + "status": "in_progress", + "run_mode": run_mode, + "run_trigger": run_trigger, + } + }, + session_service_provider=session_service_provider, + ) + + +async def append_run_checkpoint_event( + *, + session_id: str, + author: str, + run_id: str, + checkpoint_id: str, + framework: str, + framework_ref: Mapping[str, Any], + phase: str = "", + invocation_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent: + service = (session_service_provider or resolve_session_service)() + existing = await _find_latest_session_event( + service, + session_id, + lambda event: ( + event.event_type == "run_checkpoint" + and str((event.metadata or {}).get("run_id") or "") == str(run_id) + and str((event.metadata or {}).get("checkpoint_id") or "") == str(checkpoint_id) + and str((event.metadata or {}).get("framework") or "") == str(framework) + ), + ) + if existing is not None: + return existing + + event_metadata = dict(metadata or {}) + framework_ref_dict = dict(framework_ref) + langgraph_ref = framework_ref_dict.get("langgraph") + next_node = "" + if isinstance(langgraph_ref, Mapping): + next_node = str(langgraph_ref.get("next_node") or "").strip() + is_terminal = bool(event_metadata.get("is_terminal", False)) + if "is_terminal" not in event_metadata and next_node: + is_terminal = False + raw_is_resumable = event_metadata.get("is_resumable") + is_resumable: bool | None + if isinstance(raw_is_resumable, bool): + is_resumable = raw_is_resumable + else: + is_resumable = None + resume_status = str(event_metadata.get("resume_status") or "").strip() + if not resume_status: + if is_resumable is True: + resume_status = "resumable" + elif is_resumable is False: + resume_status = "disabled" + else: + resume_status = "unknown" + backend = str(event_metadata.get("backend") or "unknown").strip() or "unknown" + scope = str(event_metadata.get("scope") or "unknown").strip() or "unknown" + durable = bool(event_metadata.get("durable", False)) + event_metadata.update( + { + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "framework": str(framework), + "framework_ref": framework_ref_dict, + "phase": str(phase or ""), + "is_resumable": is_resumable, + "is_terminal": is_terminal, + "resume_status": resume_status, + "resume_disabled_reason": str(event_metadata.get("resume_disabled_reason") or ""), + "next_node": str(event_metadata.get("next_node") or next_node or ""), + "stage_key": str(event_metadata.get("stage_key") or ""), + "stage_name": str( + event_metadata.get("stage_name") + or event_metadata.get("stage") + or event_metadata.get("title") + or "" + ), + "stage_index": event_metadata.get("stage_index"), + "total_stages": event_metadata.get("total_stages"), + "backend": backend, + "scope": scope, + "durable": durable, + "artifact_preview": event_metadata.get("artifact_preview") or {}, + } + ) + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="checkpoint saved", + invocation_id=invocation_id, + event_type="run_checkpoint", + content={ + "status": "checkpointed", + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "framework": str(framework), + "is_resumable": is_resumable, + "is_terminal": is_terminal, + "resume_status": resume_status, + "resume_disabled_reason": event_metadata["resume_disabled_reason"], + "next_node": event_metadata["next_node"], + "backend": backend, + "scope": scope, + "durable": durable, + **({"phase": str(phase)} if phase else {}), + }, + metadata=event_metadata, + session_service_provider=lambda: service, + ) + + +async def append_run_resume_event( + *, + session_id: str, + author: str, + run_id: str, + checkpoint_id: str, + resume_attempt_id: str, + framework: str, + framework_ref: Mapping[str, Any], + invocation_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent: + event_metadata = dict(metadata or {}) + event_metadata.update( + { + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "resume_attempt_id": str(resume_attempt_id), + "framework": str(framework), + "framework_ref": dict(framework_ref), + } + ) + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="checkpoint resume requested", + invocation_id=invocation_id, + event_type="run_resume", + content={ + "status": "resuming", + "run_id": str(run_id), + "checkpoint_id": str(checkpoint_id), + "resume_attempt_id": str(resume_attempt_id), + "framework": str(framework), + }, + metadata=event_metadata, + session_service_provider=session_service_provider, + ) + + +async def append_reasoning_event( + *, + session_id: str, + author: str, + text: str, + invocation_id: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent | None: + """Persist assistant reasoning so hosted UI refresh can replay thinking state.""" + reasoning_text = str(text or "") + if not reasoning_text: + return None + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text=reasoning_text, + invocation_id=invocation_id, + event_type="reasoning", + metadata={"reasoning": reasoning_text, **dict(metadata or {})}, + session_service_provider=session_service_provider, + ) + + +async def append_context_checkpoint_event( + *, + session_id: str, + author: str, + compacted_until_seq_id: int, + summary_text: str = "", + trigger: str = "auto", + invocation_id: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + session_service_provider: Callable[[], Any] | None = None, +) -> SessionEvent: + """追加 compaction boundary + checkpoint summary。 + + 这里遵循 Claude Code 的大方向:边界事件和摘要事件都保留在 transcript + 里,而不是把旧 history 原地覆盖掉。 + """ + event_metadata = dict(metadata or {}) + event_metadata["compacted_until_seq_id"] = compacted_until_seq_id + event_metadata["trigger"] = trigger + await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text="", + invocation_id=invocation_id, + event_type="compaction_boundary", + content={"status": "compacted", "compacted_until_seq_id": compacted_until_seq_id}, + metadata=event_metadata, + session_service_provider=session_service_provider, + ) + return await append_conversation_event( + session_id=session_id, + author=author, + role="model", + text=summary_text, + invocation_id=invocation_id, + event_type="context_checkpoint", + metadata=event_metadata, + session_service_provider=session_service_provider, + ) diff --git a/ksadk/conversations/runtime_preparation.py b/ksadk/conversations/runtime_preparation.py new file mode 100644 index 00000000..61697095 --- /dev/null +++ b/ksadk/conversations/runtime_preparation.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +import os +from typing import Any, Callable, Dict, Mapping, Optional, Sequence + +from ksadk.conversations.attachments import compact_attachment_result_for_session +from ksadk.conversations.context import ( + build_history_from_events, + build_request_history, + build_responses_history_from_messages, + project_responses_history, +) +from ksadk.conversations.model_options import normalize_model_options +from ksadk.conversations.normalize import ( + compact_attachment_for_session, +) +from ksadk.conversations.run_kinds import ( + RUN_MODE_FOREGROUND, + RUN_TRIGGER_APPROVAL_RESUME, + RUN_TRIGGER_CHECKPOINT_RESUME, + trigger_from_resume_input, + validate_run_mode, +) +from ksadk.conversations.runtime_governance import ( + RuntimeGovernanceState, + _compact_conversation_history_with_governance, + _governance_record_approval_response, +) +from ksadk.conversations.runtime_input import ( + _merge_request_history_with_session_history, + _merge_responses_history_with_session_history, +) +from ksadk.conversations.runtime_metadata import ( + _build_attachment_context_state_delta, + _canonical_input_messages, + _latest_user_turn, + _normalized_conversation_messages, + _parts_include_file, + _resolve_effective_attachment_context, + _resolve_runtime_model_metadata, + _update_session_metadata_after_user_turn, + _user_event_content, +) +from ksadk.conversations.runtime_observability import _latest_deferred_tool_names +from ksadk.conversations.runtime_payloads import PreparedConversationTurn +from ksadk.conversations.runtime_persistence import ( + append_conversation_event, + append_run_resume_event, + append_run_status_event, + ensure_conversation_session, +) +from ksadk.conversations.runtime_resume import ( + _agentengine_resume_metadata, + _approval_decision_from_resume, + _approval_resume_run_mode, + _consecutive_approval_denials_from_events, + _execute_approved_builtin_tool_resume, + _find_tool_receipt_event_by_key, + _format_resume_response_text, + _has_pending_approval, + _is_approval_resume_input, + _is_checkpoint_resume_input, + _normalize_approval_resume_input, + _normalize_checkpoint_resume_input, + _tool_receipt_idempotency_key_for_resume, +) +from ksadk.ids import new_run_id +from ksadk.model_policy import model_policy_options_for_model +from ksadk.sessions import resolve_session_service + + +async def build_run_input( + *, + agent_id: str, + user_id: str, + session_id: Optional[str], + messages: Sequence[Dict[str, Any]], + model: Optional[str] = None, + 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, + invocation_id: Optional[str] = None, + governance_state: RuntimeGovernanceState | None = None, + session_service_provider: Callable[[], Any] | None = None, + run_mode: str = RUN_MODE_FOREGROUND, +) -> PreparedConversationTurn: + """构建一次 turn 的标准运行输入,并在进入模型前做上下文投影/压缩。 + + run_mode 由 caller 按 endpoint 语义传入(Background:true→background,普通→foreground); + run_trigger 由 resume_input 推导(new_run/checkpoint_resume/approval_resume)。 + """ + caller_run_mode = validate_run_mode(run_mode) + caller_run_trigger = trigger_from_resume_input(resume_input) + provider = session_service_provider or resolve_session_service + service = provider() + resolved_user_id = user_id + if session_id: + existing_session = await service.get_session(session_id) + if existing_session and existing_session.user_id: + resolved_user_id = existing_session.user_id + + session = await ensure_conversation_session( + agent_id=agent_id, + user_id=resolved_user_id, + session_id=session_id, + session_service_provider=provider, + ) + resolved_session_id = session.id + resolved_invocation_id = str(invocation_id or new_run_id(resolved_session_id)) + resolved_model_metadata = await _resolve_runtime_model_metadata( + model, + model_metadata=model_metadata, + ) + normalized_request_metadata = dict(request_metadata or {}) + normalized_custom_metadata = dict(custom_metadata or {}) + policy_model = model or os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") + normalized_model_options = { + **normalize_model_options(model_options), + **model_policy_options_for_model(policy_model or ""), + } + normalized_instructions = str(instructions or "").strip() + + if resume_input is not None: + if not session_id: + raise ValueError("Responses resume input requires session_id") + existing_events = await service.get_events(resolved_session_id) + normalized_resume_input = dict(resume_input) + if _is_checkpoint_resume_input(normalized_resume_input): + normalized_resume_input = _normalize_checkpoint_resume_input(normalized_resume_input) + await append_run_resume_event( + session_id=resolved_session_id, + author=agent_id, + run_id=str(normalized_resume_input["run_id"]), + checkpoint_id=str(normalized_resume_input["checkpoint_id"]), + resume_attempt_id=str(normalized_resume_input["resume_attempt_id"]), + framework=str(normalized_resume_input["framework"]), + framework_ref=normalized_resume_input["framework_ref"], + invocation_id=resolved_invocation_id, + session_service_provider=provider, + ) + # 补写 run_status(resuming):让 ActiveRunStatus 在 resume 期间正确反映"恢复中"。 + # append_run_resume_event 写的是 run_resume 事件(status=resuming),而 + # _latest_session_run_status 只扫 run_status 事件 → 不补写则 + # resuming 不进 ActiveRunStatus。 + await append_run_status_event( + session_id=resolved_session_id, + author=agent_id, + status="resuming", + invocation_id=resolved_invocation_id, + detail="checkpoint_resume", + session_service_provider=provider, + run_mode=caller_run_mode, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, + ) + event_history = await service.get_events(resolved_session_id) + history = build_history_from_events(event_history) + responses_history = project_responses_history(event_history) + return PreparedConversationTurn( + session_id=resolved_session_id, + invocation_id=resolved_invocation_id, + user_input="", + user_display_input="", + history=history, + responses_history=responses_history, + input_content=[], + input_messages=[], + user_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + model_metadata=resolved_model_metadata, + model_options=normalized_model_options, + instructions=normalized_instructions, + request_metadata={ + **normalized_request_metadata, + **_agentengine_resume_metadata(normalized_resume_input), + }, + resume_input=normalized_resume_input, + run_mode=caller_run_mode, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, + ) + + is_approval_resume = _is_approval_resume_input(normalized_resume_input) + existing_tool_receipt_event = None + if is_approval_resume and not _has_pending_approval(existing_events): + replay_candidate = _normalize_approval_resume_input( + normalized_resume_input, + existing_events, + include_resolved=True, + ) + receipt_key = _tool_receipt_idempotency_key_for_resume( + session_id=resolved_session_id, + resume_input=replay_candidate, + ) + if receipt_key: + existing_tool_receipt_event = _find_tool_receipt_event_by_key( + existing_events, + receipt_key, + ) + if existing_tool_receipt_event is not None: + normalized_resume_input = replay_candidate + else: + raise ValueError("Responses resume input requires a pending approval_request") + if is_approval_resume: + normalized_resume_input = _normalize_approval_resume_input( + normalized_resume_input, + existing_events, + ) + caller_run_mode = _approval_resume_run_mode( + existing_events, + normalized_resume_input, + fallback=caller_run_mode, + ) + if governance_state is not None: + governance_state.consecutive_approval_denials = ( + _consecutive_approval_denials_from_events(existing_events) + ) + + resume_text = _format_resume_response_text(normalized_resume_input) + resume_event_metadata: dict[str, Any] = {"resume_input": normalized_resume_input} + if normalized_resume_input.get("call_id"): + resume_event_metadata["tool_call_id"] = str(normalized_resume_input["call_id"]) + if "output" in normalized_resume_input: + resume_event_metadata["tool_output"] = normalized_resume_input.get("output") + await append_conversation_event( + session_id=resolved_session_id, + author="user", + role="user", + text=resume_text, + invocation_id=resolved_invocation_id, + event_type="approval_response" if is_approval_resume else "tool_result", + session_service_provider=provider, + metadata=resume_event_metadata, + ) + if is_approval_resume and governance_state is not None: + decision = normalized_resume_input.get("approval") + if not isinstance(decision, Mapping): + decision = _approval_decision_from_resume(normalized_resume_input) + _governance_record_approval_response(governance_state, decision) + tool_resume_input = None + if is_approval_resume: + tool_resume_input = await _execute_approved_builtin_tool_resume( + session_id=resolved_session_id, + invocation_id=resolved_invocation_id, + resume_input=normalized_resume_input, + session_service_provider=provider, + existing_events=existing_events, + ) + effective_resume_input = tool_resume_input or normalized_resume_input + del existing_events + event_history = await service.get_events(resolved_session_id) + history = build_history_from_events(event_history) + responses_history = project_responses_history(event_history) + return PreparedConversationTurn( + session_id=resolved_session_id, + invocation_id=resolved_invocation_id, + user_input=resume_text, + user_display_input=resume_text, + history=history, + responses_history=responses_history, + input_content=[], + input_messages=[], + user_parts=[], + attachments=[], + attachment_results=[], + current_attachments=[], + current_attachment_results=[], + has_current_files=False, + model_metadata=resolved_model_metadata, + model_options=normalized_model_options, + instructions=normalized_instructions, + request_metadata=normalized_request_metadata, + resume_input=effective_resume_input, + run_mode=caller_run_mode, + run_trigger=RUN_TRIGGER_APPROVAL_RESUME, + ) + + normalized_messages = _normalized_conversation_messages(messages) + ( + user_input, + user_display_input, + input_content, + user_parts, + attachments, + attachment_results, + ) = _latest_user_turn(normalized_messages) + input_messages = _canonical_input_messages(normalized_messages) + effective_attachments, effective_attachment_results = _resolve_effective_attachment_context( + normalized_messages=normalized_messages, + session=session, + ) + effective_state_delta = _build_attachment_context_state_delta( + base_state_delta=state_delta, + attachments=attachments, + attachment_results=attachment_results, + ) + event_metadata: dict[str, Any] = { + "agent_input": user_input, + "attachments": [compact_attachment_for_session(item) for item in attachments if item], + "attachment_results": [ + compact_attachment_result_for_session(item) for item in attachment_results if item + ], + } + + if normalized_instructions: + event_metadata["instructions"] = normalized_instructions + deferred_tool_names = _latest_deferred_tool_names(await service.get_events(resolved_session_id)) + if deferred_tool_names and "deferred_tool_names" not in normalized_request_metadata: + normalized_request_metadata["deferred_tool_names"] = deferred_tool_names + if normalized_custom_metadata: + event_metadata["request_metadata"] = normalized_custom_metadata + if normalized_request_metadata: + event_metadata["runtime_metadata"] = normalized_request_metadata + + await append_conversation_event( + session_id=resolved_session_id, + author="user", + role="user", + text=user_display_input or user_input, + invocation_id=resolved_invocation_id, + event_type="user_message", + state_delta=effective_state_delta, + content=_user_event_content( + user_input=user_input, + user_display_input=user_display_input, + input_content=input_content, + user_parts=user_parts, + ), + session_service_provider=provider, + metadata=event_metadata, + ) + await _update_session_metadata_after_user_turn( + service=service, + session=session, + user_input=user_input or user_display_input, + ) + + checkpoint = await _compact_conversation_history_with_governance( + governance_state, + session_id=resolved_session_id, + author=agent_id, + invocation_id=resolved_invocation_id, + model=model, + model_metadata=resolved_model_metadata, + session_service_provider=provider, + ) + event_history = await service.get_events(resolved_session_id) + history = build_history_from_events(event_history) + responses_history = project_responses_history(event_history) + request_history = build_request_history(normalized_messages[:-1]) + # Gateway / Responses callers may send full prompt context while the + # runtime-local session is empty or stale (for example after pod + # replacement). Preserve that request context, but do not duplicate it when + # local session events already contain the same prefix. + history = _merge_request_history_with_session_history(request_history, history) + request_responses_history = build_responses_history_from_messages(normalized_messages[:-1]) + responses_history = _merge_responses_history_with_session_history( + request_responses_history, + responses_history, + ) + + return PreparedConversationTurn( + session_id=resolved_session_id, + invocation_id=resolved_invocation_id, + user_input=user_input, + user_display_input=user_display_input or user_input, + history=history, + request_history=request_history, + request_responses_history=request_responses_history, + responses_history=responses_history, + input_content=input_content, + input_messages=input_messages, + user_parts=user_parts, + attachments=effective_attachments, + attachment_results=effective_attachment_results, + current_attachments=attachments, + current_attachment_results=attachment_results, + has_current_files=bool(attachments or _parts_include_file(user_parts)), + model_metadata=resolved_model_metadata, + model_options=normalized_model_options, + instructions=normalized_instructions, + request_metadata=normalized_request_metadata, + compaction_triggered=checkpoint is not None, + compaction_trigger=( + str((checkpoint.metadata or {}).get("trigger") or "auto") if checkpoint else None + ), + compacted_until_seq_id=( + int((checkpoint.metadata or {}).get("compacted_until_seq_id") or 0) + if checkpoint + else None + ), + run_mode=caller_run_mode, + run_trigger=caller_run_trigger, + ) + + +async def _refresh_history( + prepared: PreparedConversationTurn, *, session_service_provider: Callable[[], Any] | None = None +) -> PreparedConversationTurn: + """在 compaction 后刷新 prepared turn 的 history 视图。""" + provider = session_service_provider or resolve_session_service + service = provider() + event_history = await service.get_events(prepared.session_id) + prepared.history = _merge_request_history_with_session_history( + prepared.request_history, + build_history_from_events(event_history), + ) + prepared.responses_history = _merge_responses_history_with_session_history( + prepared.request_responses_history, + project_responses_history(event_history), + ) + return prepared diff --git a/ksadk/conversations/runtime_resume.py b/ksadk/conversations/runtime_resume.py new file mode 100644 index 00000000..6b3e8fc8 --- /dev/null +++ b/ksadk/conversations/runtime_resume.py @@ -0,0 +1,816 @@ +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from typing import Any, Callable, Mapping, Sequence + +from ksadk.conversations.context import ( + canonical_event_type, +) +from ksadk.conversations.model_options import normalize_model_options +from ksadk.conversations.run_kinds import ( + RUN_MODE_UNKNOWN, + validate_run_mode, +) +from ksadk.conversations.runtime_persistence import append_conversation_event +from ksadk.sessions import SessionEvent +from ksadk.tools.gateway import ( + build_tool_receipt_idempotency_key, +) + + +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 ""), + ) + if event_type == "approval_request": + pending += 1 + elif event_type == "approval_response" and pending > 0: + pending -= 1 + return pending > 0 + + +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) + 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 ""), + ) + if event_type == "approval_request": + pending.append(event) + continue + if event_type != "approval_response" or not pending: + continue + resume_input = (event.metadata or {}).get("resume_input") + response_id = "" + if isinstance(resume_input, Mapping): + response_id = str( + resume_input.get("approval_request_id") + or resume_input.get("interrupt_id") + or resume_input.get("id") + or "" + ) + if response_id: + pending = [ + item for item in pending if _approval_request_id_from_event(item) != response_id + ] + else: + pending.pop() + return pending + + +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" + ] + + +def _approval_resume_run_mode( + events: Sequence[SessionEvent], + resume_input: Mapping[str, Any], + *, + fallback: str, +) -> str: + target_ids = { + str(value) + for value in ( + resume_input.get("approval_request_id"), + resume_input.get("interrupt_id"), + resume_input.get("id"), + ) + if value + } + approval_events = _pending_approval_events(events) or _approval_request_events(events) + for approval_event in reversed(approval_events): + approval_id = _approval_request_id_from_event(approval_event) + if target_ids and approval_id not in target_ids: + continue + approval_invocation_id = str(approval_event.invocation_id or "") + if not approval_invocation_id: + continue + for event in reversed(events): + if event.event_type != "run_status" or event.invocation_id != approval_invocation_id: + continue + metadata = event.metadata or {} + state_delta = event.state_delta or {} + active_run = state_delta.get("active_run") if isinstance(state_delta, Mapping) else None + state_mode = active_run.get("run_mode") if isinstance(active_run, Mapping) else None + mode = validate_run_mode(str(metadata.get("run_mode") or state_mode or "")) + if mode != RUN_MODE_UNKNOWN: + return str(mode) + break + return str(validate_run_mode(fallback)) + + +def _parse_approval_arguments(value: Any) -> dict[str, Any]: + if isinstance(value, Mapping): + return dict(value) + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return dict(parsed) if isinstance(parsed, Mapping) else {} + return {} + + +def _approval_decision_from_resume(resume_input: Mapping[str, Any]) -> dict[str, Any]: + if "approve" in resume_input: + approved = bool(resume_input.get("approve")) + elif "approved" in resume_input: + approved = bool(resume_input.get("approved")) + else: + approved = bool( + resume_input.get("value", {}).get("approved") + if isinstance(resume_input.get("value"), Mapping) + else True + ) + decision = { + "approved": approved, + "approval_request_id": str( + resume_input.get("approval_request_id") + or resume_input.get("interrupt_id") + or resume_input.get("id") + or "" + ), + } + if resume_input.get("reason") is not None: + decision["reason"] = str(resume_input.get("reason") or "") + return decision + + +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 ""), + ) + if event_type != "approval_response": + continue + resume_input = (event.metadata or {}).get("resume_input") + if not isinstance(resume_input, Mapping): + break + decision = resume_input.get("approval") + if not isinstance(decision, Mapping): + decision = _approval_decision_from_resume(resume_input) + if bool(decision.get("approved") or decision.get("approve")): + break + denials += 1 + return denials + + +def _normalize_approval_resume_input( + resume_input: Mapping[str, Any], + events: Sequence[SessionEvent], + *, + include_resolved: bool = False, +) -> dict[str, Any]: + normalized = dict(resume_input) + if not _is_approval_resume_input(normalized): + return normalized + + approval_request_id = str( + normalized.get("approval_request_id") or normalized.get("interrupt_id") or "" + ) + pending_events = ( + _approval_request_events(events) if include_resolved else _pending_approval_events(events) + ) + matched_event = None + for event in reversed(pending_events): + if not approval_request_id or _approval_request_id_from_event(event) == approval_request_id: + matched_event = event + break + if matched_event is None: + return normalized + + interrupt_info = (matched_event.metadata or {}).get("interrupt_info") + if not isinstance(interrupt_info, Mapping): + return normalized + if not (interrupt_info.get("tool_name") or normalized.get("tool_name")): + return normalized + + decision = _approval_decision_from_resume(normalized) + tool_args = _parse_approval_arguments( + interrupt_info.get("arguments") + or interrupt_info.get("tool_args") + or interrupt_info.get("args") + or {} + ) + tool_args["approval"] = decision + + if not normalized.get("approval_request_id"): + request_id = _approval_request_id_from_event(matched_event) + if request_id: + normalized["approval_request_id"] = request_id + decision["approval_request_id"] = request_id + if interrupt_info.get("tool_name") and not normalized.get("tool_name"): + normalized["tool_name"] = str(interrupt_info.get("tool_name")) + if interrupt_info.get("run_id") and not normalized.get("run_id"): + normalized["run_id"] = str(interrupt_info.get("run_id")) + normalized["approval"] = decision + normalized["tool_args"] = tool_args + return normalized + + +def _builtin_tool_callable(tool_name: str): + name = str(tool_name or "").strip() + if not name: + return None + if name in { + "write_workspace_file", + "write_workspace_files", + "delete_workspace_file", + }: + from ksadk.toolsets import workspace + + return getattr(workspace, name, None) + if name == "execute_skills": + from ksadk.toolsets.skills import execute_skills + + return execute_skills + if name in {"run_command", "run_code"}: + from ksadk.toolsets import sandbox + + return getattr(sandbox, name, None) + return None + + +def _tool_receipt_metadata( + *, + session_id: str, + run_id: str, + tool_name: str, + tool_args: Mapping[str, Any], + tool_call_id: str | None = None, + checkpoint_id: str | None = None, + framework: str | None = None, + framework_ref: Mapping[str, Any] | None = None, + status: str = "completed", +) -> dict[str, Any]: + idempotency_key = build_tool_receipt_idempotency_key( + session_id=session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + tool_args=tool_args, + ) + return { + "receipt_id": f"tr_{idempotency_key.removeprefix('tool_receipt:')[:24]}", + "idempotency_key": idempotency_key, + "tool_name": tool_name, + "tool_call_id": tool_call_id or run_id, + "run_id": run_id, + "checkpoint_id": checkpoint_id or "", + "framework": framework or "", + "framework_ref": dict(framework_ref or {}), + "status": status, + "created_at": time.time(), + } + + +def _tool_receipt_status_from_output(output: Any) -> str: + if not isinstance(output, Mapping): + return "failed" + status = str(output.get("status") or "").strip().lower() + if status == "accepted_not_extracted": + return "completed" + return "completed" if output.get("ok") is not False else "failed" + + +def _tool_resume_run_id(resume_input: Mapping[str, Any]) -> str: + return str( + resume_input.get("run_id") + or resume_input.get("call_id") + or resume_input.get("approval_request_id") + or resume_input.get("interrupt_id") + or "" + ) + + +def _tool_receipt_idempotency_key_for_resume( + *, + session_id: str, + resume_input: Mapping[str, Any], +) -> str | None: + tool_name = str(resume_input.get("tool_name") or "").strip() + if not tool_name: + return None + tool_args = resume_input.get("tool_args") + if not isinstance(tool_args, Mapping): + return None + run_id = _tool_resume_run_id(resume_input) + if not run_id: + return None + idempotency_key = build_tool_receipt_idempotency_key( + session_id=session_id, + run_id=run_id, + tool_call_id=run_id, + tool_name=tool_name, + tool_args=dict(tool_args), + ) + return str(idempotency_key) if idempotency_key else None + + +def _find_tool_receipt_event_by_key( + events: Sequence[SessionEvent], + idempotency_key: str, +) -> SessionEvent | None: + for event in reversed(events): + if event.event_type != "tool_result": + continue + metadata = event.metadata or {} + receipt = metadata.get("tool_receipt") + if not isinstance(receipt, Mapping): + continue + if str(receipt.get("idempotency_key") or "") == idempotency_key: + return event + return None + + +def _latest_checkpoint_metadata_for_run( + events: Sequence[SessionEvent], + run_id: str, +) -> dict[str, Any]: + normalized_run_id = str(run_id or "").strip() + if not normalized_run_id: + return {} + for event in reversed(events): + if event.event_type != "run_checkpoint": + continue + metadata = event.metadata or {} + if str(metadata.get("run_id") or "").strip() != normalized_run_id: + continue + checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() + if not checkpoint_id: + continue + framework_ref = metadata.get("framework_ref") + return { + "checkpoint_id": checkpoint_id, + "framework": str(metadata.get("framework") or ""), + "framework_ref": dict(framework_ref) if isinstance(framework_ref, Mapping) else {}, + } + return {} + + +async def _execute_approved_builtin_tool_resume( + *, + session_id: str, + invocation_id: str, + resume_input: Mapping[str, Any], + session_service_provider: Callable[[], Any], + existing_events: Sequence[SessionEvent] | None = None, +) -> dict[str, Any] | None: + approval = resume_input.get("approval") + if not isinstance(approval, Mapping) or not bool(approval.get("approved")): + return None + tool_name = str(resume_input.get("tool_name") or "").strip() + tool_func = _builtin_tool_callable(tool_name) + if tool_func is None: + return None + + tool_args = resume_input.get("tool_args") + if not isinstance(tool_args, Mapping): + return None + call_args = dict(tool_args) + run_id = _tool_resume_run_id(resume_input) + service = session_service_provider() + if existing_events is None: + existing_events = await service.get_events(session_id) + checkpoint_metadata = _latest_checkpoint_metadata_for_run(existing_events, run_id) + receipt = _tool_receipt_metadata( + session_id=session_id, + run_id=run_id, + tool_call_id=run_id, + tool_name=tool_name, + tool_args=call_args, + checkpoint_id=checkpoint_metadata.get("checkpoint_id"), + framework=checkpoint_metadata.get("framework"), + framework_ref=checkpoint_metadata.get("framework_ref"), + ) + existing_event = _find_tool_receipt_event_by_key( + existing_events, + receipt["idempotency_key"], + ) + if existing_event is not None: + existing_metadata = existing_event.metadata or {} + output = existing_metadata.get("tool_output", "") + if isinstance(output, Mapping): + output = {**dict(output), "replayed": True} + replayed_receipt = { + **dict((existing_metadata.get("tool_receipt") or receipt)), + "replayed": True, + "replayed_from_event_id": existing_event.id, + } + await append_conversation_event( + session_id=session_id, + author="tool", + role="user", + text=str(output), + invocation_id=invocation_id, + event_type="tool_result", + session_service_provider=session_service_provider, + metadata={ + "tool_name": tool_name, + "tool_args": call_args, + "tool_output": output, + "run_id": run_id, + "approval_request_id": resume_input.get("approval_request_id") + or resume_input.get("interrupt_id"), + "tool_receipt": replayed_receipt, + "replayed": True, + }, + ) + return { + "type": "function_call_output", + "call_id": run_id, + "output": output, + } + + try: + if tool_name in {"run_command", "run_code"}: + output = await asyncio.to_thread(tool_func, **call_args) + else: + output = tool_func(**call_args) + except Exception as exc: + output = {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} + + receipt["status"] = _tool_receipt_status_from_output(output) + await append_conversation_event( + session_id=session_id, + author="tool", + role="user", + text=str(output), + invocation_id=invocation_id, + event_type="tool_result", + session_service_provider=session_service_provider, + metadata={ + "tool_name": tool_name, + "tool_args": call_args, + "tool_output": output, + "run_id": run_id, + "approval_request_id": resume_input.get("approval_request_id") + or resume_input.get("interrupt_id"), + "tool_receipt": receipt, + }, + ) + return { + "type": "function_call_output", + "call_id": run_id, + "output": output, + } + + +def _is_approval_resume_input(resume_input: Mapping[str, Any]) -> bool: + return str(resume_input.get("type") or "").strip() in { + "mcp_approval_response", + "ksadk_resume", + "ksadk.approval_response", + } + + +def _is_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> bool: + return str(resume_input.get("type") or "").strip() == "agentengine.resume_checkpoint" + + +def _failed_status_for_resume(resume_input: Mapping[str, Any] | None) -> str: + """checkpoint resume 失败时返回 resume_failed,否则返回 failed。 + + 仅 checkpoint resume 的失败才写 resume_failed(独立终态,触发 SSE [DONE] + 并让前端展示"恢复失败");approval/ksadk_resume 等其他 resume 失败仍写 failed。 + """ + if resume_input is not None and _is_checkpoint_resume_input(resume_input): + return "resume_failed" + return "failed" + + +def _normalize_checkpoint_resume_input(resume_input: Mapping[str, Any]) -> dict[str, Any]: + run_id = str(resume_input.get("run_id") or "").strip() + if not run_id: + raise ValueError("Checkpoint resume requires run_id") + + checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip() + if not checkpoint_id: + raise ValueError("Checkpoint resume requires checkpoint_id") + + framework = str(resume_input.get("framework") or "langgraph").strip() or "langgraph" + raw_framework_ref = resume_input.get("framework_ref") + framework_ref = dict(raw_framework_ref) if isinstance(raw_framework_ref, Mapping) else {} + raw_framework_detail = framework_ref.get(framework) + framework_detail = ( + dict(raw_framework_detail) if isinstance(raw_framework_detail, Mapping) else {} + ) + framework_detail.setdefault("checkpoint_id", checkpoint_id) + if resume_input.get("thread_id") and not framework_detail.get("thread_id"): + framework_detail["thread_id"] = str(resume_input.get("thread_id")) + framework_ref[framework] = framework_detail + + resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip() + if not resume_attempt_id: + resume_attempt_id = f"resume_{uuid.uuid4().hex}" + + return { + "type": "agentengine.resume_checkpoint", + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "resume_attempt_id": resume_attempt_id, + "framework": framework, + "framework_ref": framework_ref, + "metadata": dict(resume_input.get("metadata") or resume_input.get("Metadata") or {}), + "checkpoint_metadata": dict( + resume_input.get("checkpoint_metadata") + or resume_input.get("CheckpointMetadata") + or resume_input.get("metadata") + or resume_input.get("Metadata") + or {} + ), + "resume_instruction_enabled": bool( + resume_input.get("resume_instruction_enabled") + or resume_input.get("ResumeInstructionEnabled") + ), + "resume_instruction": str( + resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or "" + ).strip(), + } + + +def _agentengine_resume_metadata(resume_input: Mapping[str, Any] | None) -> dict[str, Any]: + if not resume_input or not _is_checkpoint_resume_input(resume_input): + return {} + return { + "agentengine": { + "action": "resume_checkpoint", + "run_id": str(resume_input.get("run_id") or ""), + "checkpoint_id": str(resume_input.get("checkpoint_id") or ""), + "resume_attempt_id": str(resume_input.get("resume_attempt_id") or ""), + "framework": str(resume_input.get("framework") or ""), + "framework_ref": dict(resume_input.get("framework_ref") or {}), + } + } + + +def _extract_agentengine_metadata(result: Mapping[str, Any] | None) -> dict[str, Any]: + if not result: + return {} + metadata = result.get("metadata") + if not isinstance(metadata, Mapping): + return {} + agentengine = metadata.get("agentengine") + if not isinstance(agentengine, Mapping): + return {} + return {"agentengine": dict(agentengine)} + + +def _checkpoint_event_args_from_agentengine_metadata( + agentengine_metadata: Mapping[str, Any] | None, + *, + fallback_run_id: str, +) -> dict[str, Any] | None: + if not isinstance(agentengine_metadata, Mapping): + return None + framework = str(agentengine_metadata.get("framework") or "langgraph").strip() or "langgraph" + raw_framework_ref = agentengine_metadata.get("framework_ref") + if not isinstance(raw_framework_ref, Mapping): + return None + framework_ref = dict(raw_framework_ref) + raw_framework_detail = framework_ref.get(framework) + if not isinstance(raw_framework_detail, Mapping): + return None + framework_detail = dict(raw_framework_detail) + checkpoint_id = str(framework_detail.get("checkpoint_id") or "").strip() + if not checkpoint_id: + return None + run_id = str(agentengine_metadata.get("run_id") or fallback_run_id or "").strip() + if not run_id: + return None + phase = str(agentengine_metadata.get("phase") or "").strip() + display_metadata = { + key: value + for key, value in agentengine_metadata.items() + if key + not in { + "run_id", + "checkpoint_id", + "framework", + "framework_ref", + "phase", + } + } + return { + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "framework": framework, + "framework_ref": framework_ref, + "phase": phase, + "metadata": display_metadata, + } + + +def _merge_agentengine_metadata( + *metadata_items: Mapping[str, Any] | None, +) -> dict[str, Any]: + merged: dict[str, Any] = {} + for metadata in metadata_items: + if not isinstance(metadata, Mapping): + continue + agentengine = metadata.get("agentengine") + if not isinstance(agentengine, Mapping): + continue + next_agentengine = dict(agentengine) + merged.update(next_agentengine) + framework_ref = agentengine.get("framework_ref") + if isinstance(framework_ref, Mapping): + merged_framework_ref = merged.get("framework_ref") + existing_framework_ref = ( + merged_framework_ref if isinstance(merged_framework_ref, Mapping) else {} + ) + merged["framework_ref"] = { + **dict(existing_framework_ref), + **dict(framework_ref), + } + return {"agentengine": merged} if merged else {} + + +def _format_resume_response_text(resume_input: Mapping[str, Any]) -> str: + item_type = str(resume_input.get("type") or "resume") + if item_type == "mcp_approval_response": + approval_request_id = str(resume_input.get("approval_request_id") or "") + approve = resume_input.get("approve") + reason = str(resume_input.get("reason") or "").strip() + parts = [f"mcp_approval_response approval_request_id={approval_request_id}"] + if approve is not None: + parts.append(f"approve={bool(approve)}") + if reason: + parts.append(f"reason={reason}") + return " ".join(parts) + + if item_type == "function_call_output": + output = resume_input.get("output", "") + if isinstance(output, (dict, list)): + output_text = json.dumps(output, ensure_ascii=False, sort_keys=True) + else: + output_text = str(output) + return ( + f"function_call_output call_id={resume_input.get('call_id') or ''} output={output_text}" + ) + + return f"{item_type} {json.dumps(dict(resume_input), ensure_ascii=False, sort_keys=True)}" + + +def _stringify_responses_item_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, indent=2) + return str(value) + + +def _responses_output_item_text(item: Mapping[str, Any]) -> str: + for text_field in ("output_text", "text", "summary_text", "delta"): + value = item.get(text_field) + if isinstance(value, str) and value: + return value + summary = item.get("summary") + if isinstance(summary, Sequence) and not isinstance(summary, (str, bytes, bytearray)): + parts: list[str] = [] + for part in summary: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, Mapping): + text = part.get("text") or part.get("summary_text") + if isinstance(text, str): + parts.append(text) + if parts: + return "".join(parts) + content = item.get("content") + if isinstance(content, Sequence) and not isinstance(content, (str, bytes, bytearray)): + parts = [] + for part in content: + if isinstance(part, str): + parts.append(part) + elif isinstance(part, Mapping): + text = part.get("text") + if isinstance(text, str): + parts.append(text) + return "".join(parts) + if isinstance(content, str): + return content + return "" + + +def _semantic_events_from_responses_output(output: Sequence[Any]) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + tool_names: dict[str, str] = {} + for raw_item in output: + if not isinstance(raw_item, Mapping): + continue + item = dict(raw_item) + item_type = str(item.get("type") or "").strip() + item_id = str(item.get("id") or item.get("item_id") or "") + call_id = str(item.get("call_id") or "") + if item_type == "function_call": + name = str(item.get("name") or item.get("tool_name") or "tool") + args = _stringify_responses_item_value( + item.get("arguments") + if "arguments" in item + else item.get("args", item.get("input")) + ) + if item_id: + tool_names[item_id] = name + if call_id: + tool_names[call_id] = name + events.append( + { + "type": "tool_call", + "name": name, + "args": args, + "run_id": call_id or item_id or None, + } + ) + continue + if item_type == "function_call_output": + name = ( + tool_names.get(call_id) + or tool_names.get(item_id) + or str(item.get("name") or "tool") + ) + output_text = _stringify_responses_item_value( + item.get("output") if "output" in item else item.get("result", item.get("content")) + ) + events.append( + { + "type": "tool_result", + "name": name, + "output": output_text, + "run_id": call_id or item_id or None, + } + ) + continue + if item_type in {"reasoning", "reasoning_summary", "reasoning_summary_text"}: + text = _responses_output_item_text(item) + if text: + events.append({"type": "thinking", "delta": text}) + return events + + +def _model_options_disable_reasoning(model_options: Mapping[str, Any] | None) -> bool: + normalized = normalize_model_options(model_options) + reasoning = normalized.get("reasoning") + if isinstance(reasoning, Mapping): + effort = str(reasoning.get("effort") or "").strip().lower() + if effort in {"none", "off", "disabled", "disable", "false", "0"}: + return True + max_reasoning_tokens = normalized.get("max_reasoning_tokens") + if max_reasoning_tokens is not None: + try: + return int(max_reasoning_tokens) <= 0 + except (TypeError, ValueError): + pass + thinking = normalized.get("thinking") + if isinstance(thinking, Mapping): + raw_type = str(thinking.get("type") or thinking.get("status") or "").strip().lower() + return raw_type in {"disabled", "disable", "off", "none", "false", "0"} + if isinstance(thinking, bool): + return not thinking + raw_thinking = str(thinking or "").strip().lower() + return raw_thinking in {"disabled", "disable", "off", "none", "false", "0"} + + +def _filter_responses_reasoning_output(output: Sequence[Any]) -> list[Any]: + filtered: list[Any] = [] + for raw_item in output: + if isinstance(raw_item, Mapping): + item_type = str(raw_item.get("type") or "").strip() + if item_type in {"reasoning", "reasoning_summary", "reasoning_summary_text"}: + continue + filtered.append(raw_item) + return filtered diff --git a/ksadk/conversations/runtime_stream_events.py b/ksadk/conversations/runtime_stream_events.py new file mode 100644 index 00000000..a757142e --- /dev/null +++ b/ksadk/conversations/runtime_stream_events.py @@ -0,0 +1,902 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, AsyncIterator, Callable, Dict, Mapping, Optional, Sequence + +from ksadk.conversations.run_kinds import ( + RUN_MODE_FOREGROUND, + trigger_from_resume_input, + validate_run_mode, +) +from ksadk.conversations.runtime_compaction import preview_auto_compaction +from ksadk.conversations.runtime_constants import ( + ASSISTANT_STREAM_SNAPSHOT_INTERVAL_SECONDS, + ASSISTANT_STREAM_SNAPSHOT_MIN_NEW_CHARS, + PTL_RETRY_KEEP_TAIL_GROUPS, +) +from ksadk.conversations.runtime_governance import ( + RuntimeCircuitOpen, + _compact_conversation_history_with_governance, + _governance_record_tool_call, + _governance_record_tool_result, + _governance_record_turn_start, + _runtime_governance_from_env, + _tool_observability_metadata, +) +from ksadk.conversations.runtime_input import ( + _auto_save_ltm_turn, + _build_runner_ambient_contexts, + _build_runner_request_payload, + _inject_runner_deferred_tools_for_request, + _is_prompt_too_long_error, + _runner_name, + _runner_type_name, +) +from ksadk.conversations.runtime_metadata import _update_session_metadata_after_assistant_turn +from ksadk.conversations.runtime_observability import ( + _extract_deferred_tool_names, + _get_conversation_tracer, + _normalize_usage_payload, + _set_conversation_input_attributes, + _set_conversation_output_attributes, + _set_conversation_span_attributes, + _set_conversation_usage_attributes, + _set_span_attribute, + _span_current_context, + _span_feedback_metadata, +) +from ksadk.conversations.runtime_payloads import CompactionPlan +from ksadk.conversations.runtime_persistence import ( + append_conversation_event, + append_deferred_tools_event, + append_reasoning_event, + append_run_checkpoint_event, + append_run_status_event, +) +from ksadk.conversations.runtime_preparation import _refresh_history, build_run_input +from ksadk.conversations.runtime_resume import ( + _checkpoint_event_args_from_agentengine_metadata, + _extract_agentengine_metadata, + _failed_status_for_resume, + _filter_responses_reasoning_output, + _is_checkpoint_resume_input, + _latest_checkpoint_metadata_for_run, + _merge_agentengine_metadata, + _model_options_disable_reasoning, + _semantic_events_from_responses_output, + _tool_receipt_metadata, +) +from ksadk.model_policy import fallback_model_for_exception, model_policy_options_for_model +from ksadk.runtime_context import ( + PlatformInvocationContext, + platform_invocation_scope, + tool_execution_scope, +) +from ksadk.sessions import resolve_session_service +from ksadk.tools.gateway import ( + approval_interrupt_info_from_result, +) + + +async def _iter_conversation_turn_events( + *, + runner: Any, + agent_id: str, + user_id: str, + session_id: Optional[str], + messages: Sequence[Dict[str, Any]], + model: Optional[str], + prepare_runner: Callable[[Any, Optional[str]], None], + 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, + response_id: str | 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, +) -> AsyncIterator[dict[str, Any]]: + """Internal semantic event stream shared by protocol serializers.""" + provider = session_service_provider or resolve_session_service + prepare_runner(runner, model) + governance = _runtime_governance_from_env() + _governance_record_turn_start(governance) + entry_run_mode = validate_run_mode(run_mode) + entry_run_trigger = trigger_from_resume_input(resume_input) + if resume_input is None: + 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, + ) + else: + compaction_preview = CompactionPlan( + should_compact=False, + groups_to_compact=[], + total_chars=0, + total_estimated_tokens=0, + group_count=0, + tail_groups=0, + ) + if compaction_preview.should_compact: + yield { + "type": "compaction", + "phase": "start", + "trigger": "auto", + "total_chars": compaction_preview.total_chars, + "total_estimated_tokens": compaction_preview.total_estimated_tokens, + "group_count": compaction_preview.group_count, + "threshold_percentage": compaction_preview.auto_compact_threshold_percentage, + } + try: + 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, + governance_state=governance, + session_service_provider=provider, + run_mode=entry_run_mode, + ) + # prepared 之后的 run_status 写入复用 prepared 的 mode/trigger + run_mode = prepared.run_mode + run_trigger = prepared.run_trigger + except RuntimeCircuitOpen as exc: + if session_id: + await append_run_status_event( + session_id=session_id, + author=_runner_name(runner), + status=_failed_status_for_resume(resume_input), + invocation_id=invocation_id, + detail=str(exc), + metadata={"governance": exc.metadata}, + session_service_provider=provider, + run_mode=entry_run_mode, + run_trigger=entry_run_trigger, + ) + yield {"type": "error", "message": str(exc) or "Agent 运行失败"} + return + _inject_runner_deferred_tools_for_request(runner, prepared) + ambient_contexts = _build_runner_ambient_contexts( + runner=runner, + user_id=user_id, + user_input=prepared.user_input, + ) + runtime_context = PlatformInvocationContext( + agent_id=agent_id, + user_id=user_id, + account_id=str(account_id or ""), + session_id=prepared.session_id, + history=list(prepared.history), + input_content=list(prepared.input_content), + input_messages=list(prepared.input_messages), + input_parts=list(prepared.user_parts), + attachments=list(prepared.attachments), + attachment_results=list(prepared.attachment_results), + current_attachments=list(prepared.current_attachments), + current_attachment_results=list(prepared.current_attachment_results), + has_current_files=prepared.has_current_files, + runner_type=_runner_type_name(runner), + metadata=dict(custom_metadata or {}), + model=model, + 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 "" + ), + ) + if prepared.compaction_triggered: + yield { + "type": "compaction", + "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 + ), + "total_estimated_tokens": ( + compaction_preview.total_estimated_tokens + if compaction_preview.should_compact + else None + ), + "group_count": ( + compaction_preview.group_count if compaction_preview.should_compact else None + ), + "threshold_percentage": ( + compaction_preview.auto_compact_threshold_percentage + if compaction_preview.should_compact + else None + ), + } + runner_name = _runner_name(runner) + tracer = _get_conversation_tracer() + span = tracer.start_span(runner_name) if tracer else None + span_ended = False + + def _finish_span() -> None: + nonlocal span_ended + if span is None or span_ended: + return + span_ended = True + try: + span.end() + except Exception: + pass + + try: + _set_conversation_span_attributes( + span, + agent_id=agent_id, + user_id=user_id, + session_id=prepared.session_id, + invocation_id=prepared.invocation_id, + runner_name=runner_name, + model=model, + response_id=response_id, + ) + _set_conversation_input_attributes(span, prepared.user_input or prepared.user_display_input) + trace_metadata = _span_feedback_metadata(span) + yield { + "type": "started", + "session_id": prepared.session_id, + "metadata": {**trace_metadata, **dict(request_metadata or {})}, + } + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="in_progress", + invocation_id=prepared.invocation_id, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + + accumulated_text = "" + last_snapshot_text = "" + last_snapshot_at = 0.0 + snapshot_index = 0 + accumulated_reasoning_parts: list[str] = [] + emitted_anything = False + emitted_response_artifacts = False + saw_final_chunk = False + responses_output: list[Any] = [] + responses_response_id: str | None = response_id + runner_agentengine_metadata: dict[str, Any] = {} + stream_usage: dict[str, Any] = {} + stream_last_usage: dict[str, Any] = {} + reasoning_disabled = _model_options_disable_reasoning(prepared.model_options) + + async def _persist_accumulated_reasoning(*, before_text: bool = False) -> None: + if not accumulated_reasoning_parts: + return + reasoning = "".join(accumulated_reasoning_parts) + accumulated_reasoning_parts.clear() + await append_reasoning_event( + session_id=prepared.session_id, + author=runner_name, + text=reasoning, + invocation_id=prepared.invocation_id, + metadata={"stream_boundary": "before_text"} if before_text else None, + session_service_provider=provider, + ) + + async def _persist_assistant_snapshot(*, force: bool = False) -> None: + """Persist a bounded replay point for detached stream recovery.""" + nonlocal last_snapshot_at, last_snapshot_text, snapshot_index + if not accumulated_text or accumulated_text == last_snapshot_text: + return + now = time.monotonic() + if ( + last_snapshot_text + and not force + and len(accumulated_text) - len(last_snapshot_text) + < ASSISTANT_STREAM_SNAPSHOT_MIN_NEW_CHARS + and now - last_snapshot_at < ASSISTANT_STREAM_SNAPSHOT_INTERVAL_SECONDS + ): + return + snapshot_index += 1 + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model", + text=accumulated_text, + invocation_id=prepared.invocation_id, + event_type="assistant_stream_snapshot", + metadata={"stream_snapshot": True, "snapshot_index": snapshot_index}, + session_service_provider=provider, + ) + last_snapshot_text = accumulated_text + last_snapshot_at = now + + for attempt in range(2): + try: + runtime_context.history = list(prepared.history) + with platform_invocation_scope(runtime_context): + with tool_execution_scope( + session_id=prepared.session_id, + run_id=prepared.invocation_id, + invocation_id=prepared.invocation_id, + ): + stream = runner.stream( + _build_runner_request_payload( + prepared=prepared, + model=model, + runtime_context=runtime_context, + runner=runner, + ) + ) + while True: + try: + with _span_current_context(span): + chunk = await anext(stream) + except StopAsyncIteration: + break + chunk_type = chunk.get("type") + if chunk_type == "checkpoint": + emitted_anything = True + chunk_agentengine_metadata = _extract_agentengine_metadata(chunk) + if chunk_agentengine_metadata: + runner_agentengine_metadata.update(chunk_agentengine_metadata) + resume_run_id = "" + if prepared.resume_input and _is_checkpoint_resume_input( + prepared.resume_input + ): + resume_run_id = str( + prepared.resume_input.get("run_id") or "" + ).strip() + checkpoint_args = ( + _checkpoint_event_args_from_agentengine_metadata( + runner_agentengine_metadata.get("agentengine"), + fallback_run_id=resume_run_id or prepared.invocation_id, + ) + ) + if checkpoint_args: + await append_run_checkpoint_event( + session_id=prepared.session_id, + author=runner_name, + run_id=checkpoint_args["run_id"], + checkpoint_id=checkpoint_args["checkpoint_id"], + framework=checkpoint_args["framework"], + framework_ref=checkpoint_args["framework_ref"], + phase=checkpoint_args.get("phase") or "stream", + invocation_id=prepared.invocation_id, + metadata=checkpoint_args.get("metadata"), + session_service_provider=provider, + ) + continue + if chunk_type == "responses_output": + if isinstance(chunk.get("usage"), Mapping): + stream_usage = _normalize_usage_payload(chunk.get("usage")) + chunk_last = (chunk.get("metadata") or {}).get("last_usage") + if isinstance(chunk_last, Mapping): + stream_last_usage = ( + _normalize_usage_payload(chunk_last) or stream_usage + ) + raw_output = chunk.get("output") + responses_output = ( + raw_output if isinstance(raw_output, list) else [] + ) + if reasoning_disabled: + responses_output = _filter_responses_reasoning_output( + responses_output + ) + raw_response_id = chunk.get("response_id") + responses_response_id = ( + str(raw_response_id) + if raw_response_id + else responses_response_id + ) + if responses_output and not emitted_response_artifacts: + for semantic_event in _semantic_events_from_responses_output( + responses_output + ): + if semantic_event.get("type") == "thinking": + accumulated_reasoning_parts.append( + str(semantic_event.get("delta") or "") + ) + emitted_anything = True + yield semantic_event + continue + if chunk_type == "thinking": + if reasoning_disabled: + continue + delta = str(chunk.get("delta", "")) + if delta: + # Close the preceding text segment before + # a new thought begins. Snapshot throttling + # must not erase this timeline boundary. + await _persist_assistant_snapshot(force=True) + accumulated_reasoning_parts.append(delta) + emitted_anything = True + emitted_response_artifacts = True + yield {"type": "thinking", "delta": delta} + continue + if chunk_type == "text": + delta = str(chunk.get("delta", "")) + if delta: + # A reasoning event must be written before + # the text snapshot it explains. Without + # this boundary, historical replay only + # sees one merged reasoning blob after all + # streamed text and cannot rebuild an + # interleaved turn. + await _persist_accumulated_reasoning(before_text=True) + replace = bool(chunk.get("replace")) + accumulated_text = ( + delta if replace else accumulated_text + delta + ) + emitted_anything = True + await _persist_assistant_snapshot(force=replace) + text_event: dict[str, Any] = { + "type": "text", + "delta": delta, + } + if replace: + text_event["replace"] = True + yield text_event + continue + if chunk_type == "tool_call": + await _persist_assistant_snapshot(force=True) + _governance_record_tool_call(governance) + emitted_response_artifacts = True + tool_args = chunk.get("tool_args", {}) + if not isinstance(tool_args, Mapping): + tool_args = {} + tool_call_id = str( + chunk.get("call_id") or chunk.get("run_id") or "" + ).strip() + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model", + text=str(chunk.get("tool_name") or "tool"), + invocation_id=prepared.invocation_id, + event_type="tool_call", + metadata={ + "tool_name": chunk.get("tool_name"), + "tool_args": dict(tool_args), + "run_id": chunk.get("run_id"), + "tool_call_id": tool_call_id, + "stage": chunk.get("stage") or tool_args.get("stage"), + "event_kind": chunk.get("event_kind"), + "display_title": chunk.get("display_title"), + "display_summary": chunk.get("display_summary"), + }, + session_service_provider=provider, + ) + emitted_anything = True + await _persist_accumulated_reasoning() + yield { + "type": "tool_call", + "name": chunk.get("tool_name"), + "args": dict(tool_args), + "run_id": chunk.get("run_id"), + "stage": chunk.get("stage") or tool_args.get("stage"), + "event_kind": chunk.get("event_kind"), + "display_title": chunk.get("display_title"), + "display_summary": chunk.get("display_summary"), + } + continue + if chunk_type in {"stage_tool_call", "stage_tool_result"}: + await _persist_assistant_snapshot(force=True) + emitted_response_artifacts = True + tool_name = str( + chunk.get("tool_name") or chunk.get("name") or "tool" + ) + tool_args = chunk.get("tool_args", chunk.get("args", {})) + if not isinstance(tool_args, Mapping): + tool_args = {} + tool_output = chunk.get("tool_output", chunk.get("output", "")) + event_kind = str(chunk.get("event_kind") or "") + display_title = str(chunk.get("display_title") or tool_name) + display_summary = str( + chunk.get("display_summary") or chunk.get("text") or "" + ) + tool_call_id = str( + chunk.get("call_id") or chunk.get("run_id") or "" + ).strip() + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model" if chunk_type == "stage_tool_call" else "user", + text=display_summary or tool_name, + invocation_id=prepared.invocation_id, + event_type=chunk_type, + metadata={ + "tool_name": tool_name, + "tool_args": dict(tool_args), + "tool_output": tool_output, + "run_id": chunk.get("run_id"), + "tool_call_id": tool_call_id, + "stage": chunk.get("stage") or tool_args.get("stage"), + "event_kind": event_kind, + "display_title": display_title, + "display_summary": display_summary, + }, + session_service_provider=provider, + ) + emitted_anything = True + yield { + "type": chunk_type, + "name": tool_name, + "args": dict(tool_args), + "output": tool_output, + "run_id": chunk.get("run_id"), + "stage": chunk.get("stage") or tool_args.get("stage"), + "event_kind": event_kind, + "display_title": display_title, + "display_summary": display_summary, + } + continue + if chunk_type == "tool_result": + await _persist_assistant_snapshot(force=True) + emitted_response_artifacts = True + tool_name = str(chunk.get("tool_name") or "tool") + tool_args = chunk.get("tool_args", {}) + if not isinstance(tool_args, Mapping): + tool_args = {} + tool_run_id = str(chunk.get("run_id") or prepared.invocation_id) + tool_call_id = str( + chunk.get("call_id") or chunk.get("run_id") or tool_run_id + ).strip() + checkpoint_metadata = _latest_checkpoint_metadata_for_run( + await provider().get_events(prepared.session_id), + tool_run_id, + ) + approval_interrupt_info = approval_interrupt_info_from_result( + chunk.get("tool_output", ""), + fallback_tool_name=tool_name, + tool_args=tool_args, + run_id=tool_run_id, + ) + if approval_interrupt_info: + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model", + text="approval requested", + invocation_id=prepared.invocation_id, + event_type="approval_request", + metadata={"interrupt_info": approval_interrupt_info}, + session_service_provider=provider, + ) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="interrupted", + invocation_id=prepared.invocation_id, + detail="approval_required", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + emitted_anything = True + await _persist_accumulated_reasoning() + yield { + "type": "interrupt", + "interrupt_info": approval_interrupt_info, + "session_id": prepared.session_id, + "metadata": {**trace_metadata, **prepared.request_metadata}, + } + return + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="user", + text=str(chunk.get("tool_output", "")), + invocation_id=prepared.invocation_id, + event_type="tool_result", + metadata={ + "tool_name": tool_name, + "tool_output": chunk.get("tool_output", ""), + "run_id": tool_run_id, + "tool_call_id": tool_call_id, + "observability": _tool_observability_metadata( + tool_name, chunk.get("tool_output", "") + ), + "tool_receipt": _tool_receipt_metadata( + session_id=prepared.session_id, + run_id=tool_run_id, + tool_call_id=tool_call_id, + tool_name=tool_name, + tool_args=tool_args, + checkpoint_id=checkpoint_metadata.get("checkpoint_id"), + framework=checkpoint_metadata.get("framework"), + framework_ref=checkpoint_metadata.get("framework_ref"), + status=( + "failed" + if isinstance(chunk.get("tool_output"), Mapping) + and chunk.get("tool_output", {}).get("ok") is False + else "completed" + ), + ), + }, + session_service_provider=provider, + ) + deferred_tool_names = _extract_deferred_tool_names( + chunk.get("tool_output", "") + ) + if tool_name == "tool_search" and deferred_tool_names: + await append_deferred_tools_event( + session_id=prepared.session_id, + author=runner_name, + deferred_tool_names=deferred_tool_names, + invocation_id=prepared.invocation_id, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + _governance_record_tool_result( + governance, chunk.get("tool_output", "") + ) + emitted_anything = True + await _persist_accumulated_reasoning() + yield { + "type": "tool_result", + "name": chunk.get("tool_name"), + "output": chunk.get("tool_output", ""), + "run_id": chunk.get("run_id"), + } + continue + if chunk_type in ("interrupt", "approval"): + await _persist_assistant_snapshot(force=True) + # langgraph_runner 流式路径冒 `approval`(含 HITL action_requests); + # invoke 路径冒 `interrupt`。两者统一走 approval_request 通道。 + interrupt_info = chunk.get("interrupt_info") + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model", + text="approval requested", + invocation_id=prepared.invocation_id, + event_type="approval_request", + metadata={"interrupt_info": interrupt_info}, + session_service_provider=provider, + ) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="interrupted", + invocation_id=prepared.invocation_id, + detail="approval_required", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + emitted_anything = True + yield { + "type": "interrupt", + "interrupt_info": interrupt_info, + "session_id": prepared.session_id, + "metadata": {**trace_metadata, **prepared.request_metadata}, + } + return + if chunk_type == "final": + saw_final_chunk = True + final_text = str(chunk.get("output", "")) + if final_text: + accumulated_text = final_text + if isinstance(chunk.get("usage"), Mapping): + stream_usage = _normalize_usage_payload(chunk.get("usage")) + chunk_last = (chunk.get("metadata") or {}).get("last_usage") + if isinstance(chunk_last, Mapping): + stream_last_usage = ( + _normalize_usage_payload(chunk_last) or stream_usage + ) + break + except asyncio.CancelledError: + await _persist_accumulated_reasoning() + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="cancelled", + invocation_id=prepared.invocation_id, + detail="cancel_requested", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + yield { + "type": "cancelled", + "session_id": prepared.session_id, + "metadata": {**trace_metadata, **prepared.request_metadata}, + } + return + except Exception as exc: + if attempt == 0 and not emitted_anything and _is_prompt_too_long_error(exc): + yield {"type": "compaction", "phase": "start", "trigger": "prompt_too_long"} + try: + checkpoint = await _compact_conversation_history_with_governance( + governance, + session_id=prepared.session_id, + author=runner_name, + invocation_id=prepared.invocation_id, + model=model, + model_metadata=prepared.model_metadata, + force=True, + trigger="prompt_too_long", + keep_tail_groups=PTL_RETRY_KEEP_TAIL_GROUPS, + session_service_provider=provider, + ) + except RuntimeCircuitOpen as circuit_exc: + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status=_failed_status_for_resume(resume_input), + invocation_id=prepared.invocation_id, + detail=str(circuit_exc), + metadata={"governance": circuit_exc.metadata}, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + await _persist_accumulated_reasoning() + yield {"type": "error", "message": str(circuit_exc) or "Agent 运行失败"} + return + if checkpoint: + yield { + "type": "compaction", + "phase": "done", + "trigger": "prompt_too_long", + "compacted_until_seq_id": int( + (checkpoint.metadata or {}).get("compacted_until_seq_id") or 0 + ) + or None, + } + prepared = await _refresh_history( + prepared, session_service_provider=provider + ) + runtime_context.history = list(prepared.history) + continue + if attempt == 0 and not emitted_anything: + fallback_model = fallback_model_for_exception(exc, current_model=model or "") + if fallback_model: + model = fallback_model + prepare_runner(runner, fallback_model) + prepared.model_options = { + **prepared.model_options, + **model_policy_options_for_model(fallback_model), + } + runtime_context.model = fallback_model + runtime_context.model_options = prepared.model_options + _set_span_attribute(span, "ksadk.model.fallback", fallback_model) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="in_progress", + invocation_id=prepared.invocation_id, + detail=f"fallback_model:{fallback_model}", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + continue + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status=_failed_status_for_resume(resume_input), + invocation_id=prepared.invocation_id, + detail=str(exc), + metadata=( + {"governance": exc.metadata} + if isinstance(exc, RuntimeCircuitOpen) + else None + ), + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + await _persist_accumulated_reasoning() + yield {"type": "error", "message": str(exc) or "Agent 运行失败"} + return + + request_metadata_without_agentengine = { + key: value for key, value in prepared.request_metadata.items() if key != "agentengine" + } + assistant_metadata = { + **trace_metadata, + **request_metadata_without_agentengine, + **_merge_agentengine_metadata(prepared.request_metadata, runner_agentengine_metadata), + } + if responses_output: + assistant_metadata["responses_output"] = responses_output + if stream_usage: + assistant_metadata["usage"] = stream_usage + if stream_last_usage: + assistant_metadata["last_usage"] = stream_last_usage + elif stream_usage: + assistant_metadata["last_usage"] = stream_usage + if responses_response_id: + assistant_metadata["response_id"] = responses_response_id + if emitted_anything and not saw_final_chunk and not accumulated_text: + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status=_failed_status_for_resume(resume_input), + invocation_id=prepared.invocation_id, + detail="runner_stream_ended_without_final_output", + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + await _persist_accumulated_reasoning() + _finish_span() + yield { + "type": "error", + "message": "Agent 运行流已结束,但没有返回最终输出。", + "session_id": prepared.session_id, + "metadata": { + **assistant_metadata, + "detail": "runner_stream_ended_without_final_output", + }, + } + return + _set_conversation_output_attributes(span, accumulated_text) + + await _persist_accumulated_reasoning() + await append_conversation_event( + session_id=prepared.session_id, + author=runner_name, + role="model", + text=accumulated_text, + invocation_id=prepared.invocation_id, + event_type="assistant_message", + metadata=assistant_metadata or None, + session_service_provider=provider, + ) + await _update_session_metadata_after_assistant_turn( + service=provider(), + session_id=prepared.session_id, + assistant_text=accumulated_text, + model=model, + ) + await _auto_save_ltm_turn( + agent_id=agent_id, + user_id=user_id, + prepared=prepared, + output_text=accumulated_text, + runner_type=runtime_context.runner_type, + model=model, + ) + await append_run_status_event( + session_id=prepared.session_id, + author=runner_name, + status="completed", + invocation_id=prepared.invocation_id, + session_service_provider=provider, + run_mode=run_mode, + run_trigger=run_trigger, + ) + _set_conversation_usage_attributes(span, assistant_metadata.get("usage")) + _finish_span() + yield { + "type": "completed", + "output_text": accumulated_text, + "model": model, + "session_id": prepared.session_id, + "metadata": assistant_metadata, + "responses_output": responses_output, + "response_id": responses_response_id, + "usage": assistant_metadata.get("usage"), + } + finally: + _finish_span() diff --git a/ksadk/conversations/runtime_streaming.py b/ksadk/conversations/runtime_streaming.py new file mode 100644 index 00000000..82f6099e --- /dev/null +++ b/ksadk/conversations/runtime_streaming.py @@ -0,0 +1,547 @@ +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, AsyncIterator, Callable, Dict, Mapping, Optional, Sequence + +from ksadk.conversations.run_kinds import ( + RUN_MODE_FOREGROUND, +) +from ksadk.conversations.runtime_invocation import _response_sse +from ksadk.conversations.runtime_payloads import ( + build_compaction_sse_event, + build_responses_payload, +) +from ksadk.conversations.runtime_stream_events import _iter_conversation_turn_events + + +async def stream_conversation_turn( + *, + runner: Any, + agent_id: str, + user_id: str, + session_id: Optional[str], + messages: Sequence[Dict[str, Any]], + model: Optional[str], + prepare_runner: Callable[[Any, Optional[str]], None], + 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, +) -> AsyncIterator[str]: + """Legacy ksadk response SSE stream used by hosted chat and chat-completions.""" + 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, + account_id=account_id, + invocation_id=invocation_id, + session_service_provider=session_service_provider, + run_mode=run_mode, + ): + event_type = event.get("type") + if event_type == "compaction": + yield build_compaction_sse_event( + phase=str(event.get("phase") or "start"), + trigger=str(event.get("trigger") or "auto"), + compacted_until_seq_id=event.get("compacted_until_seq_id"), + total_chars=event.get("total_chars"), + total_estimated_tokens=event.get("total_estimated_tokens"), + group_count=event.get("group_count"), + threshold_percentage=event.get("threshold_percentage"), + ) + elif event_type == "thinking": + yield _response_sse("response.reasoning.delta", {"delta": event.get("delta", "")}) + elif event_type == "text": + text_payload: dict[str, Any] = {"delta": event.get("delta", "")} + if event.get("replace"): + text_payload["replace"] = True + yield _response_sse("response.output_text.delta", text_payload) + elif event_type == "tool_call": + yield _response_sse( + "response.tool_call", + { + "name": event.get("name"), + "args": event.get("args", {}), + "run_id": event.get("run_id"), + "stage": event.get("stage"), + "event_kind": event.get("event_kind"), + "display_title": event.get("display_title"), + "display_summary": event.get("display_summary"), + }, + ) + elif event_type == "tool_result": + yield _response_sse( + "response.tool_result", + { + "name": event.get("name"), + "output": event.get("output", ""), + "run_id": event.get("run_id"), + }, + ) + elif event_type in {"stage_tool_call", "stage_tool_result"}: + yield _response_sse( + f"response.ksadk.{event_type}", + { + "type": event_type, + "name": event.get("name"), + "args": event.get("args", {}), + "output": event.get("output", ""), + "run_id": event.get("run_id"), + "stage": event.get("stage"), + "event_kind": event.get("event_kind"), + "display_title": event.get("display_title"), + "display_summary": event.get("display_summary"), + }, + ) + elif event_type == "interrupt": + yield _response_sse( + "response.approval_request", {"interrupt_info": event.get("interrupt_info")} + ) + elif event_type == "error": + yield _response_sse( + "response.error", {"message": event.get("message") or "Agent 运行失败"} + ) + elif event_type == "cancelled": + yield _response_sse("response.cancelled", {"status": "cancelled"}) + elif event_type == "completed": + final_payload = build_responses_payload( + output_text=str(event.get("output_text") or ""), + model=event.get("model") or model, + session_id=str(event.get("session_id") or session_id or ""), + metadata=( + event.get("metadata") if isinstance(event.get("metadata"), Mapping) else None + ), + usage=event.get("usage") if isinstance(event.get("usage"), Mapping) else None, + ) + yield _response_sse("response.completed", final_payload) + + +async def stream_responses_conversation_turn( + *, + runner: Any, + agent_id: str, + user_id: str, + session_id: Optional[str], + messages: Sequence[Dict[str, Any]], + model: Optional[str], + prepare_runner: Callable[[Any, Optional[str]], None], + 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, +) -> AsyncIterator[str]: + """OpenAI Responses-style SSE stream.""" + response_id = f"resp_{uuid.uuid4().hex}" + created_at = int(time.time()) + response_metadata = dict(custom_metadata or {}) + + message_item_id = f"msg_{uuid.uuid4().hex[:12]}" + reasoning_item_id = f"rs_{uuid.uuid4().hex[:12]}" + next_output_index = 0 + text_output_index: int | None = None + reasoning_output_index: int | None = None + message_started = False + content_started = False + reasoning_started = False + completed_text = "" + lifecycle_started = False + + def _start_response_lifecycle(current_session_id: str | None = None) -> list[str]: + nonlocal lifecycle_started, response_metadata + if lifecycle_started: + return [] + lifecycle_started = True + initial_payload = build_responses_payload( + output_text="", + model=model, + session_id=current_session_id or session_id or "", + response_id=response_id, + created_at=created_at, + status="in_progress", + metadata=response_metadata, + ) + return [ + _response_sse("response.created", initial_payload), + _response_sse("response.in_progress", initial_payload), + ] + + def _message_item(status: str, text: str = "") -> dict[str, Any]: + content = [{"type": "output_text", "text": text}] if text or status == "completed" else [] + return { + "id": message_item_id, + "type": "message", + "status": status, + "role": "assistant", + "content": content, + } + + def _reasoning_item(status: str) -> dict[str, Any]: + return { + "id": reasoning_item_id, + "type": "reasoning", + "status": status, + "summary": [], + } + + def _next_output_index() -> int: + nonlocal next_output_index + output_index = next_output_index + 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, + ): + if include_agentengine_metadata: + event_metadata = event.get("metadata") + agentengine_metadata = ( + event_metadata.get("agentengine") if isinstance(event_metadata, Mapping) else None + ) + if isinstance(agentengine_metadata, Mapping): + response_metadata["agentengine"] = dict(agentengine_metadata) + if not lifecycle_started: + for lifecycle_chunk in _start_response_lifecycle(str(event.get("session_id") or "")): + yield lifecycle_chunk + event_type = event.get("type") + if event_type == "compaction": + yield build_compaction_sse_event( + phase=str(event.get("phase") or "start"), + trigger=str(event.get("trigger") or "auto"), + compacted_until_seq_id=event.get("compacted_until_seq_id"), + total_chars=event.get("total_chars"), + total_estimated_tokens=event.get("total_estimated_tokens"), + group_count=event.get("group_count"), + threshold_percentage=event.get("threshold_percentage"), + ) + continue + + if event_type == "thinking": + if not reasoning_started: + reasoning_started = True + reasoning_output_index = _next_output_index() + yield _response_sse( + "response.output_item.added", + { + "output_index": reasoning_output_index, + "item": _reasoning_item("in_progress"), + }, + ) + yield _response_sse( + "response.reasoning.delta", + { + "item_id": reasoning_item_id, + "output_index": reasoning_output_index, + "delta": event.get("delta", ""), + }, + ) + continue + + if event_type == "text": + if not message_started: + message_started = True + text_output_index = _next_output_index() + yield _response_sse( + "response.output_item.added", + {"output_index": text_output_index, "item": _message_item("in_progress")}, + ) + if not content_started: + content_started = True + yield _response_sse( + "response.content_part.added", + { + "item_id": message_item_id, + "output_index": text_output_index, + "content_index": 0, + "part": {"type": "output_text", "text": ""}, + }, + ) + delta = str(event.get("delta") or "") + replace = bool(event.get("replace")) + completed_text = delta if replace else completed_text + delta + text_delta_payload: dict[str, Any] = { + "item_id": message_item_id, + "output_index": text_output_index, + "content_index": 0, + "delta": delta, + } + if replace: + text_delta_payload["replace"] = True + yield _response_sse( + "response.output_text.delta", + text_delta_payload, + ) + continue + + if event_type == "tool_call": + args_json = json.dumps(event.get("args", {}) or {}, ensure_ascii=False) + call_id = str(event.get("run_id") or f"call_{uuid.uuid4().hex[:12]}") + item_id = f"fc_{uuid.uuid4().hex[:12]}" + call_output_index = _next_output_index() + item = { + "id": item_id, + "type": "function_call", + "status": "in_progress", + "call_id": call_id, + "name": event.get("name") or "unknown", + "arguments": "", + } + yield _response_sse( + "response.output_item.added", {"output_index": call_output_index, "item": item} + ) + yield _response_sse( + "response.function_call_arguments.delta", + {"item_id": item_id, "output_index": call_output_index, "delta": args_json}, + ) + item["arguments"] = args_json + item["status"] = "completed" + yield _response_sse( + "response.function_call_arguments.done", + {"item_id": item_id, "output_index": call_output_index, "arguments": args_json}, + ) + yield _response_sse( + "response.output_item.done", {"output_index": call_output_index, "item": item} + ) + if event.get("display_title") or event.get("display_summary"): + yield _response_sse( + "response.ksadk.tool_call", + { + "type": "tool_call", + "name": event.get("name"), + "args": event.get("args", {}), + "run_id": event.get("run_id"), + "stage": event.get("stage"), + "event_kind": event.get("event_kind"), + "display_title": event.get("display_title"), + "display_summary": event.get("display_summary"), + }, + ) + continue + + if event_type == "tool_result": + yield _response_sse( + "response.ksadk.tool_result", + { + "name": event.get("name"), + "output": event.get("output", ""), + "run_id": event.get("run_id"), + }, + ) + continue + + if event_type in {"stage_tool_call", "stage_tool_result"}: + yield _response_sse( + f"response.ksadk.{event_type}", + { + "type": event_type, + "name": event.get("name"), + "args": event.get("args", {}), + "output": event.get("output", ""), + "run_id": event.get("run_id"), + "stage": event.get("stage"), + "event_kind": event.get("event_kind"), + "display_title": event.get("display_title"), + "display_summary": event.get("display_summary"), + }, + ) + continue + + if event_type == "interrupt": + interrupt_info = event.get("interrupt_info") + if isinstance(interrupt_info, Mapping) and interrupt_info.get("tool_name"): + raw_arguments = ( + interrupt_info.get("arguments") + or interrupt_info.get("tool_args") + or interrupt_info.get("args") + or {} + ) + arguments = ( + raw_arguments + if isinstance(raw_arguments, str) + else json.dumps(raw_arguments, ensure_ascii=False) + ) + approval_item = { + "id": str( + interrupt_info.get("approval_request_id") + or interrupt_info.get("id") + or f"appr_{uuid.uuid4().hex[:12]}" + ), + "type": "mcp_approval_request", + "name": str(interrupt_info.get("tool_name")), + "arguments": arguments, + "server_label": str(interrupt_info.get("server_label") or "ksadk"), + } + approval_output_index = _next_output_index() + yield _response_sse( + "response.output_item.added", + {"output_index": approval_output_index, "item": approval_item}, + ) + yield _response_sse( + "response.output_item.done", + {"output_index": approval_output_index, "item": approval_item}, + ) + else: + yield _response_sse( + "response.ksadk.approval_request", {"interrupt_info": interrupt_info} + ) + incomplete_payload = build_responses_payload( + output_text=completed_text, + model=model, + session_id=str(event.get("session_id") or session_id or ""), + response_id=response_id, + created_at=created_at, + status="incomplete", + metadata=response_metadata, + incomplete_details={ + "reason": "approval_required", + "ksadk_interrupt": interrupt_info, + }, + ) + yield _response_sse("response.incomplete", incomplete_payload) + return + + if event_type == "error": + failed_payload = build_responses_payload( + output_text=completed_text, + model=model, + session_id=session_id or "", + response_id=response_id, + created_at=created_at, + status="failed", + metadata=response_metadata, + usage=event.get("usage") if isinstance(event.get("usage"), Mapping) else None, + error={"message": event.get("message") or "Agent 运行失败"}, + ) + yield _response_sse("response.failed", failed_payload) + return + + if event_type == "cancelled": + cancelled_payload = build_responses_payload( + output_text=completed_text, + model=model, + session_id=str(event.get("session_id") or session_id or ""), + response_id=response_id, + created_at=created_at, + status="cancelled", + metadata=response_metadata, + usage=event.get("usage") if isinstance(event.get("usage"), Mapping) else None, + ) + yield _response_sse("response.cancelled", cancelled_payload) + return + + if event_type == "completed": + completed_text = str(event.get("output_text") or completed_text) + if completed_text and not message_started: + message_started = True + text_output_index = _next_output_index() + yield _response_sse( + "response.output_item.added", + {"output_index": text_output_index, "item": _message_item("in_progress")}, + ) + content_started = True + yield _response_sse( + "response.content_part.added", + { + "item_id": message_item_id, + "output_index": text_output_index, + "content_index": 0, + "part": {"type": "output_text", "text": ""}, + }, + ) + if message_started: + yield _response_sse( + "response.output_text.done", + { + "item_id": message_item_id, + "output_index": text_output_index, + "content_index": 0, + "text": completed_text, + }, + ) + yield _response_sse( + "response.content_part.done", + { + "item_id": message_item_id, + "output_index": text_output_index, + "content_index": 0, + "part": {"type": "output_text", "text": completed_text}, + }, + ) + yield _response_sse( + "response.output_item.done", + { + "output_index": text_output_index, + "item": _message_item("completed", completed_text), + }, + ) + if reasoning_started: + yield _response_sse( + "response.output_item.done", + {"output_index": reasoning_output_index, "item": _reasoning_item("completed")}, + ) + final_payload = build_responses_payload( + output_text=completed_text, + model=event.get("model") or model, + session_id=str(event.get("session_id") or session_id or ""), + response_id=str(event.get("response_id") or response_id), + created_at=created_at, + status="completed", + metadata=response_metadata, + usage=event.get("usage") if isinstance(event.get("usage"), Mapping) else None, + output_items=( + event.get("responses_output") + if isinstance(event.get("responses_output"), Sequence) + else None + ), + ) + yield _response_sse("response.completed", final_payload) + return + + if not lifecycle_started: + for lifecycle_chunk in _start_response_lifecycle(): + yield lifecycle_chunk diff --git a/ksadk/conversations/semantic_summary.py b/ksadk/conversations/semantic_summary.py index 5cc8562f..bc6ffa2e 100644 --- a/ksadk/conversations/semantic_summary.py +++ b/ksadk/conversations/semantic_summary.py @@ -11,7 +11,11 @@ build_compaction_prompt_messages, extract_summary_text, ) -from ksadk.conversations.context import canonical_event_type, extract_event_text, summarize_event_groups +from ksadk.conversations.context import ( + canonical_event_type, + extract_event_text, + summarize_event_groups, +) from ksadk.sessions.base import SessionEvent SUMMARY_VERSION = "v1" @@ -25,7 +29,8 @@ # 已知局限(有意取舍,对齐 Claude Code 行为): # - 默认 0(opt-in),避免 transient 失败永久禁用 semantic;用户显式设 N>0 才启用。 # - 打开后无 half-open 探测,需进程重启或显式 _reset_semantic_failures 恢复(CC 同样如此)。 -# - 计数为模块级全局,跨 session/model 共享;per-model key 化留作 follow-up(对齐 governance per-session 范式)。 +# - 计数为模块级全局,跨 session/model 共享。 +# - per-model key 化留作 follow-up(对齐 governance per-session 范式)。 _semantic_summary_failures: int = 0 @@ -118,7 +123,9 @@ async def summarize( } timeout_seconds = max(1.0, float(timeout_ms) / 1000.0) async with httpx.AsyncClient(timeout=timeout_seconds) as client: - response = await client.post(self._chat_completions_url(), headers=headers, json=payload) + response = await client.post( + self._chat_completions_url(), headers=headers, json=payload + ) response.raise_for_status() data = response.json() @@ -280,7 +287,9 @@ def _build_semantic_input( if len(selected_groups) > max_groups: skipped_groups = selected_groups[:-max_groups] selected_groups = selected_groups[-max_groups:] - skipped_summary = summarize_event_groups(skipped_groups, previous_summary=merged_previous_summary) + skipped_summary = summarize_event_groups( + skipped_groups, previous_summary=merged_previous_summary + ) merged_previous_summary = skipped_summary return merged_previous_summary, selected_groups @@ -295,7 +304,10 @@ async def summarize_compaction( ) -> CompactionSummaryResult: """语义摘要优先,失败后自动回退到 extractive。""" - fallback_summary = summarize_event_groups(list(groups_to_compact), previous_summary=previous_summary) + fallback_summary = summarize_event_groups( + [list(group) for group in groups_to_compact], + previous_summary=previous_summary, + ) if semantic_compaction_disabled(): return CompactionSummaryResult( summary_text=fallback_summary, diff --git a/ksadk/conversations/session_title.py b/ksadk/conversations/session_title.py index 61651067..569fdeb8 100644 --- a/ksadk/conversations/session_title.py +++ b/ksadk/conversations/session_title.py @@ -112,7 +112,6 @@ def build_heuristic_title(*, first_prompt: str, assistant_text: str) -> str: prompt = _normalize_source_text(first_prompt) assistant = _normalize_source_text(assistant_text) combined = f"{prompt} {assistant}".strip() - prompt_lower = prompt.lower() combined_lower = combined.lower() if _SELF_INTRO_RE.search(prompt): @@ -126,7 +125,9 @@ def build_heuristic_title(*, first_prompt: str, assistant_text: str) -> str: if _RECRUIT_RE.search(combined_lower): if "简历" in combined_lower or "候选人" in combined_lower: return "简历分析" - if "面试" in combined_lower and (_ANALYZE_RE.search(combined) or _FILE_RE.search(combined_lower)): + if "面试" in combined_lower and ( + _ANALYZE_RE.search(combined) or _FILE_RE.search(combined_lower) + ): return "面试分析" if _FILE_RE.search(combined_lower): @@ -230,7 +231,9 @@ async def generate_title( } timeout_seconds = max(1.0, float(timeout_ms) / 1000.0) async with httpx.AsyncClient(timeout=timeout_seconds) as client: - response = await client.post(self._chat_completions_url(), headers=headers, json=payload) + response = await client.post( + self._chat_completions_url(), headers=headers, json=payload + ) response.raise_for_status() data = response.json() diff --git a/ksadk/deployment/__init__.py b/ksadk/deployment/__init__.py index b82bba07..003dfee9 100644 --- a/ksadk/deployment/__init__.py +++ b/ksadk/deployment/__init__.py @@ -7,62 +7,61 @@ - serverless: 金山云 Serverless 计算引擎 """ +# 导入并注册所有 Provider +from ksadk.deployment import providers as providers from ksadk.deployment.base import ( BaseDeployProvider, - DeployTarget, DeployResult, DeployStatus, + DeployTarget, + NetworkConfig, PackageInfo, ResourceSpec, ScalingConfig, - NetworkConfig, ) from ksadk.deployment.registry import DeployProviderRegistry -# 导入并注册所有 Provider -from ksadk.deployment import providers - class DeploymentManager: """部署管理器 - 统一入口 - + 使用方式: manager = DeploymentManager() provider = manager.get_provider("docker") result = await provider.deploy(package_info, target) """ - + @staticmethod - def get_provider(name: str, config: dict = None) -> BaseDeployProvider: + def get_provider(name: str, config: dict | None = None) -> BaseDeployProvider: """获取部署 Provider - + Args: name: Provider 名称 (docker, k8s, serverless) config: Provider 配置 - + Returns: Provider 实例 """ return DeployProviderRegistry.get(name, config) - + @staticmethod def list_providers() -> list: """列出所有可用 Provider""" return DeployProviderRegistry.get_provider_info() - + @staticmethod def has_provider(name: str) -> bool: """检查 Provider 是否存在""" return DeployProviderRegistry.has_provider(name) - + # 向后兼容: create() 方法 @classmethod - def create(cls, target: str) -> DeployTarget: + def create(cls, target: str) -> BaseDeployProvider: """创建部署目标 - + Args: target: 部署目标 (docker, k8s, serverless) - + Returns: 部署目标实例 """ diff --git a/ksadk/deployment/agent_access.py b/ksadk/deployment/agent_access.py index 94736b20..76a79553 100644 --- a/ksadk/deployment/agent_access.py +++ b/ksadk/deployment/agent_access.py @@ -5,7 +5,6 @@ from contextlib import nullcontext from typing import Any, Awaitable, Callable, Mapping, Sequence - AgentAccessDetailFetcher = Callable[[str, bool], Awaitable[dict[str, Any]]] AgentAccessErrorHandler = Callable[[Exception], None] @@ -16,16 +15,13 @@ def canonicalize_agent_access_detail(detail: Mapping[str, Any] | None) -> dict[s return {} normalized = dict(detail) - basic = normalized.get("basic") if isinstance(normalized.get("basic"), Mapping) else {} - quick = ( - normalized.get("quick_access") - if isinstance(normalized.get("quick_access"), Mapping) - else {} - ) - deployment = ( - normalized.get("deployment") - if isinstance(normalized.get("deployment"), Mapping) - else {} + basic_value = normalized.get("basic") + basic: Mapping[str, Any] = basic_value if isinstance(basic_value, Mapping) else {} + quick_value = normalized.get("quick_access") + quick: Mapping[str, Any] = quick_value if isinstance(quick_value, Mapping) else {} + deployment_value = normalized.get("deployment") + deployment: Mapping[str, Any] = ( + deployment_value if isinstance(deployment_value, Mapping) else {} ) def _pick(*values: Any) -> Any: @@ -74,9 +70,8 @@ def is_agent_not_visible_yet_error(exc: Exception) -> bool: text = str(exc or "").lower() if not text: return False - return ( - ("http 404" in text or "status=404" in text or "code: 404" in text) - and ("未找到对应的 agent" in text or "not found" in text) + return ("http 404" in text or "status=404" in text or "code: 404" in text) and ( + "未找到对应的 agent" in text or "not found" in text ) @@ -99,7 +94,7 @@ def is_agent_not_found_error(exc: Exception) -> bool: if isinstance(details, Mapping): http_status = details.get("http_status") try: - if int(http_status) == 404: + if http_status is not None and int(http_status) == 404: return True except (TypeError, ValueError): pass @@ -174,11 +169,7 @@ async def _default_detail_fetcher(ref: str, fetch_api_key: bool) -> dict[str, An fetcher = detail_fetcher or _default_detail_fetcher retry_delay_values = [float(item) for item in (retry_delays or ())] - attempts = ( - 1 + len(retry_delay_values) - if retry_delay_values - else max(1, int(attempts or 1)) - ) + attempts = 1 + len(retry_delay_values) if retry_delay_values else max(1, int(attempts or 1)) last_exc: Exception | None = None suppress_ctx = nullcontext() @@ -205,9 +196,7 @@ async def _default_detail_fetcher(ref: str, fetch_api_key: bool) -> dict[str, An if delay > 0: await asyncio.sleep(delay) try: - detail = canonicalize_agent_access_detail( - await fetcher(agent_ref, include_api_key) - ) + detail = canonicalize_agent_access_detail(await fetcher(agent_ref, include_api_key)) except Exception as exc: last_exc = exc if attempt < attempts - 1 and is_agent_not_visible_yet_error(exc): diff --git a/ksadk/deployment/base.py b/ksadk/deployment/base.py index 48367a4d..df8e9a49 100644 --- a/ksadk/deployment/base.py +++ b/ksadk/deployment/base.py @@ -3,13 +3,15 @@ """ from abc import ABC, abstractmethod -from typing import Dict, Any, Optional, List -from pydantic import BaseModel, Field from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field class DeployStatus(str, Enum): """部署状态""" + PENDING = "pending" PACKAGING = "packaging" BUILDING = "building" @@ -26,12 +28,14 @@ class DeployStatus(str, Enum): class ResourceSpec(BaseModel): """资源规格""" + cpu: str = "2" memory: str = "4Gi" class ScalingConfig(BaseModel): """扩缩容配置""" + min_replicas: int = 1 max_replicas: int = 10 concurrency: int = 10 @@ -39,6 +43,7 @@ class ScalingConfig(BaseModel): class NetworkConfig(BaseModel): """网络配置""" + access_type: str = "public" # public | private enable_https: bool = True enable_public_access: Optional[bool] = None @@ -58,10 +63,11 @@ class StorageSpec(BaseModel): class DeployTarget(BaseModel): """部署目标配置""" - provider: str # serverless | faas | k8s | docker + + provider: str # serverless | faas | k8s | docker region: str = "cn-north-1" project_id: str = "default" - + # 通用配置 resources: ResourceSpec = Field(default_factory=ResourceSpec) scaling: ScalingConfig = Field(default_factory=ScalingConfig) @@ -74,22 +80,24 @@ class DeployTarget(BaseModel): class DeployResult(BaseModel): """部署结果""" + status: DeployStatus = DeployStatus.UNKNOWN agent_id: Optional[str] = None agent_name: Optional[str] = None endpoint: Optional[str] = None api_key: Optional[str] = None message: str = "" - + # 额外元数据 metadata: Dict[str, Any] = Field(default_factory=dict) - + def is_success(self) -> bool: return self.status in (DeployStatus.RUNNING, DeployStatus.DEPLOYING) class PackageInfo(BaseModel): """打包信息""" + name: str framework: str build_dir: str @@ -97,132 +105,137 @@ class PackageInfo(BaseModel): dockerfile: Optional[str] = None image: Optional[str] = None entry_point: str = "agent.py" - + # 额外信息 metadata: Dict[str, Any] = Field(default_factory=dict) class BaseDeployProvider(ABC): """部署 Provider 基类 - + 所有部署 Provider 必须继承此类并实现抽象方法。 """ - + # Provider 标识 name: str = "base" display_name: str = "Base Provider" description: str = "" - + # 能力标识 supports_streaming: bool = False supports_scaling: bool = False requires_image_registry: bool = False - - def __init__(self, config: Dict[str, Any] = None): + + def __init__(self, config: Optional[Dict[str, Any]] = None): """初始化 Provider - + Args: config: Provider 级别配置 (如 API Key、区域默认值等) """ self.config = config or {} - + @abstractmethod async def validate_config(self, target: DeployTarget) -> tuple[bool, str]: """验证部署配置 - + Args: target: 部署目标配置 - + Returns: (is_valid, error_message) """ pass - + @abstractmethod - async def package(self, project_dir: str, detection_result: Any, config: Dict[str, Any] = None) -> PackageInfo: + async def package( + self, + project_dir: str, + detection_result: Any, + config: Optional[Dict[str, Any]] = None, + ) -> PackageInfo: """打包项目 - + Args: project_dir: 项目目录 detection_result: 框架检测结果 config: 项目配置 (agentengine.yaml) - + Returns: 打包信息 """ pass - + @abstractmethod async def build(self, package_info: PackageInfo, target: DeployTarget) -> PackageInfo: """构建镜像/代码包 - + Args: package_info: 打包信息 target: 部署目标 - + Returns: 更新后的打包信息 (含镜像地址等) """ pass - + @abstractmethod async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> DeployResult: """部署 - + Args: package_info: 打包信息 target: 部署目标 - + Returns: 部署结果 """ pass - + @abstractmethod async def get_status(self, agent_id: str, target: DeployTarget) -> DeployResult: """获取部署状态 - + Args: agent_id: Agent ID target: 部署目标 - + Returns: 部署状态 """ pass - + @abstractmethod async def destroy(self, agent_id: str, target: DeployTarget) -> bool: """销毁部署 - + Args: agent_id: Agent ID target: 部署目标 - + Returns: 是否成功 """ pass - + async def list_agents(self, target: DeployTarget) -> List[DeployResult]: """列出所有 Agent (可选实现) - + Args: target: 部署目标 - + Returns: Agent 列表 """ raise NotImplementedError(f"{self.name} does not support list_agents") - + async def invoke(self, agent_id: str, message: str, target: DeployTarget) -> str: """调用 Agent (可选实现) - + Args: agent_id: Agent ID message: 输入消息 target: 部署目标 - + Returns: Agent 响应 """ diff --git a/ksadk/deployment/manager.py b/ksadk/deployment/manager.py index 9a53d625..adb4a57f 100644 --- a/ksadk/deployment/manager.py +++ b/ksadk/deployment/manager.py @@ -3,10 +3,8 @@ """ from abc import ABC, abstractmethod -from typing import Dict, Any, Literal from pathlib import Path -import shutil -import os +from typing import Any, Dict, Literal from ksadk.builders.framework_requirements import ( FASTAPI_REQUIREMENT, @@ -17,12 +15,12 @@ class BaseDeployer(ABC): """部署器基类""" - + @abstractmethod def package(self, project_dir: str, detection_result: Any) -> Dict[str, Any]: """打包项目""" pass - + @abstractmethod def deploy(self, package_info: Dict[str, Any], **kwargs) -> Dict[str, Any]: """部署""" @@ -31,61 +29,59 @@ def deploy(self, package_info: Dict[str, Any], **kwargs) -> Dict[str, Any]: class K8sDeployer(BaseDeployer): """K8s 部署器 (本地模拟)""" - + def package(self, project_dir: str, detection_result: Any) -> Dict[str, Any]: """打包项目并生成 K8s 配置""" project_path = Path(project_dir) output_dir = project_path / ".ksadk" / "deploy" output_dir.mkdir(parents=True, exist_ok=True) - + # 生成 Dockerfile dockerfile = self._generate_dockerfile(detection_result) dockerfile_path = output_dir / "Dockerfile" dockerfile_path.write_text(dockerfile) - + # 生成 requirements.txt requirements = self._generate_requirements(detection_result) requirements_path = output_dir / "requirements.txt" requirements_path.write_text(requirements) - + # 生成 K8s 部署配置 k8s_config = self._generate_k8s_config() k8s_path = output_dir / "deployment.yaml" k8s_path.write_text(k8s_config) - + # 生成启动脚本 entrypoint = self._generate_entrypoint(detection_result) entrypoint_path = output_dir / "entrypoint.py" entrypoint_path.write_text(entrypoint) - + return { "output_dir": str(output_dir), "dockerfile": str(dockerfile_path), "requirements": str(requirements_path), "config_path": str(k8s_path), "entrypoint": str(entrypoint_path), - "framework": detection_result.type.value + "framework": detection_result.type.value, } - + def deploy(self, package_info: Dict[str, Any], **kwargs) -> Dict[str, Any]: """部署到 K8s (本地模拟)""" name = kwargs.get("name", "ksadk-agent") namespace = kwargs.get("namespace", "default") port = kwargs.get("port", 8000) - + # 检查 kubectl 是否可用 import subprocess + try: result = subprocess.run( - ["kubectl", "version", "--client"], - capture_output=True, - text=True, - timeout=10 + ["kubectl", "version", "--client"], capture_output=True, text=True, timeout=10 ) kubectl_available = result.returncode == 0 except Exception: kubectl_available = False - + if not kubectl_available: print("⚠️ kubectl 不可用,使用模拟部署模式") return { @@ -93,9 +89,9 @@ def deploy(self, package_info: Dict[str, Any], **kwargs) -> Dict[str, Any]: "endpoint": f"http://localhost:{port}", "message": "kubectl not available, deployment simulated", "name": name, - "namespace": namespace + "namespace": namespace, } - + # 实际部署到 K8s config_path = package_info.get("config_path") if config_path: @@ -104,43 +100,35 @@ def deploy(self, package_info: Dict[str, Any], **kwargs) -> Dict[str, Any]: config_content = config_content.replace("{{NAME}}", name) config_content = config_content.replace("{{NAMESPACE}}", namespace) config_content = config_content.replace("{{PORT}}", str(port)) - + temp_config = Path(package_info["output_dir"]) / "deployment_final.yaml" temp_config.write_text(config_content) - + try: result = subprocess.run( ["kubectl", "apply", "-f", str(temp_config)], capture_output=True, text=True, - timeout=60 + timeout=60, ) - + if result.returncode == 0: return { "status": "deployed", "endpoint": f"http://{name}.{namespace}.svc.cluster.local:{port}", "name": name, "namespace": namespace, - "kubectl_output": result.stdout + "kubectl_output": result.stdout, } else: - return { - "status": "failed", - "error": result.stderr, - "name": name - } + return {"status": "failed", "error": result.stderr, "name": name} except Exception as e: - return { - "status": "error", - "error": str(e), - "name": name - } - + return {"status": "error", "error": str(e), "name": name} + return {"status": "error", "message": "No config path provided"} - + def _generate_dockerfile(self, detection_result: Any) -> str: - return f'''FROM python:3.12-slim + return """FROM python:3.12-slim WORKDIR /app @@ -152,8 +140,8 @@ def _generate_dockerfile(self, detection_result: Any) -> str: EXPOSE 8000 CMD ["python", "entrypoint.py"] -''' - +""" + def _generate_requirements(self, detection_result: Any) -> str: base_deps = [ FASTAPI_REQUIREMENT, @@ -161,14 +149,14 @@ def _generate_requirements(self, detection_result: Any) -> str: "python-dotenv>=1.0.0", "pydantic>=2.0.0", ] - + framework = detection_result.type.value base_deps += minimal_requirements_for_framework(framework) - + return "\n".join(merge_requirement_lists(base_deps)) - + def _generate_k8s_config(self) -> str: - return '''apiVersion: apps/v1 + return """apiVersion: apps/v1 kind: Deployment metadata: name: {{NAME}} @@ -215,8 +203,8 @@ def _generate_k8s_config(self) -> str: - port: {{PORT}} targetPort: {{PORT}} type: ClusterIP -''' - +""" + def _generate_entrypoint(self, detection_result: Any) -> str: package_name = Path(detection_result.package_path).name return f'''""" @@ -255,7 +243,7 @@ def _generate_entrypoint(self, detection_result: Any) -> str: runner.load_agent() # 设置 Runner 到 FastAPI app -set_runner(runner) +set_runner(runner, loaded=True) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) @@ -264,69 +252,79 @@ def _generate_entrypoint(self, detection_result: Any) -> str: class DockerDeployer(K8sDeployer): """Docker 部署器""" - + def deploy(self, package_info: Dict[str, Any], **kwargs) -> Dict[str, Any]: """构建并运行 Docker 容器""" import subprocess - + name = kwargs.get("name", "ksadk-agent") port = kwargs.get("port", 8000) output_dir = package_info.get("output_dir") - + if output_dir is None: + return {"status": "failed", "error": "package output_dir is missing"} + # 构建镜像 print(f"🔨 构建 Docker 镜像: {name}") try: result = subprocess.run( - ["docker", "build", "-t", name, "-f", package_info["dockerfile"], output_dir], + [ + "docker", + "build", + "-t", + str(name), + "-f", + str(package_info["dockerfile"]), + str(output_dir), + ], capture_output=True, text=True, - timeout=300 + timeout=300, ) - + if result.returncode != 0: return {"status": "failed", "error": result.stderr} - - print(f"✅ 镜像构建成功") - + + print("✅ 镜像构建成功") + # 运行容器 result = subprocess.run( ["docker", "run", "-d", "-p", f"{port}:8000", "--name", f"{name}-container", name], capture_output=True, text=True, - timeout=30 + timeout=30, ) - + if result.returncode == 0: return { "status": "running", "endpoint": f"http://localhost:{port}", "container_id": result.stdout.strip()[:12], - "name": name + "name": name, } else: return {"status": "failed", "error": result.stderr} - + except Exception as e: return {"status": "error", "error": str(e)} class DeploymentManager: """部署管理器工厂""" - + _deployers = { "k8s": K8sDeployer, "docker": DockerDeployer, } - + @classmethod def create(cls, target: Literal["k8s", "docker", "faas"]) -> BaseDeployer: """创建部署器""" if target == "faas": # FaaS 暂时使用 K8s 部署器模拟 target = "k8s" - + deployer_class = cls._deployers.get(target) if not deployer_class: raise ValueError(f"不支持的部署目标: {target}") - + return deployer_class() diff --git a/ksadk/deployment/providers/serverless.py b/ksadk/deployment/providers/serverless.py index 13b411cd..d4d16f99 100644 --- a/ksadk/deployment/providers/serverless.py +++ b/ksadk/deployment/providers/serverless.py @@ -6,38 +6,37 @@ - Deploy 阶段: 客户端调用 AgentEngine Server API 发起部署 """ -import os import json import logging -import shutil +import os import time -import yaml from pathlib import Path -from typing import Dict, Any, List, Optional +from typing import Any, Dict, List, Optional + import click +import yaml -from ksadk.builders.ks3_uploader import KS3Uploader +from ksadk.api import AgentEngineClient, DryRunExit from ksadk.builders.code_builder import CodeBuilder +from ksadk.builders.container_builder import ( + ContainerBuilder, + registry_kind_label, + resolve_registry_credentials, +) +from ksadk.builders.ks3_uploader import KS3Uploader +from ksadk.configs.env_registry import ENV_VAR_REGISTRY +from ksadk.configs.global_config import get_env_from_global_config +from ksadk.configs.settings import DEFAULT_RUNTIME_TIMEZONE from ksadk.deployment.agent_access import get_latest_agent_access from ksadk.deployment.base import ( BaseDeployProvider, - DeployTarget, DeployResult, DeployStatus, + DeployTarget, PackageInfo, ) from ksadk.deployment.registry import DeployProviderRegistry from ksadk.deployment.ui_config import resolve_ui_config, ui_config_to_state_fields -from ksadk.builders.container_builder import ( - ContainerBuilder, - registry_kind_label, - resolve_registry_credentials, -) -from ksadk.configs.settings import DEFAULT_RUNTIME_TIMEZONE -from ksadk.configs.env_registry import ENV_VAR_REGISTRY -from ksadk.configs.global_config import get_env_from_global_config -from ksadk.api import AgentEngineClient, DryRunExit - logger = logging.getLogger(__name__) @@ -71,11 +70,7 @@ ) _DEPLOY_PROCESS_ENV_PREFIXES = ("KSADK_", "OPENAI_", "KSYUN_", "E2B_") _DEPLOY_PROCESS_ENV_DENYLIST = frozenset( - { - spec.name - for spec in ENV_VAR_REGISTRY - if spec.module in {"builders", "cli", "configs", "web"} - } + {spec.name for spec in ENV_VAR_REGISTRY if spec.module in {"builders", "cli", "configs", "web"}} ) | frozenset( { "KSADK_GLOBAL_CONFIG_ENV_KEYS", @@ -96,7 +91,7 @@ def _should_forward_process_env(name: str) -> bool: @DeployProviderRegistry.register("kce") class ServerlessProvider(BaseDeployProvider): """金山云 Serverless 计算引擎 (AgentEngine Server 托管) - + 重构后直接继承 BaseDeployProvider,不再依赖 DockerProvider。 Container 模式使用 ContainerBuilder 进行打包和构建。 """ @@ -109,7 +104,7 @@ class ServerlessProvider(BaseDeployProvider): supports_scaling = True requires_image_registry = False - def __init__(self, config: Dict[str, Any] = None): + def __init__(self, config: Optional[Dict[str, Any]] = None): self.config = config or {} @staticmethod @@ -124,13 +119,15 @@ def _image_credential_from_env(image_ref: str) -> Optional[Dict[str, str]]: if password and not username and registry_kind != "personal_kcr": click.secho( - f" ⚠️ 检测到 KCR_PASSWORD 但缺少 KCR_USERNAME,已忽略{registry_kind_label(registry_kind)}镜像凭证;" + f" ⚠️ 检测到 KCR_PASSWORD 但缺少 KCR_USERNAME," + f"已忽略{registry_kind_label(registry_kind)}镜像凭证;" "企业版 KCR 和第三方镜像仓库必须配置 KCR_USERNAME + KCR_PASSWORD", fg="yellow", ) elif registry_kind == "personal_kcr": click.secho( - " ⚠️ 未配置个人版 KCR 镜像凭证 (KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取", + " ⚠️ 未配置个人版 KCR 镜像凭证 " + "(KSYUN_ACCOUNT_ID/KCR_PASSWORD),私有镜像可能无法拉取", fg="yellow", ) else: @@ -269,7 +266,7 @@ def _serialize_network_config( return None public_access = getattr(network, "enable_public_access", None) - # 三态:None=未指定。create 默认开公网;update 未指定则不发 network 字段(保留服务端现有配置)。 + # 三态:None=未指定。create 默认开公网;update 未指定则不发 network 字段。 if public_access is None: public_value: Optional[bool] = None if is_update else True else: @@ -317,19 +314,19 @@ def _format_elapsed(started_at: float) -> str: seconds = int(elapsed % 60) return f"{minutes}m{seconds:02d}s" - async def validate_config(self, target: DeployTarget) -> tuple[bool, str]: """验证配置: 确保已配置 AgentEngine Server""" - + server_url = os.getenv("AGENTENGINE_SERVER_URL") - + if not server_url: click.echo("⚠️ 未配置 AGENTENGINE_SERVER_URL,将尝试使用默认 Region 配置") - + # Container 模式: 检查 Docker 是否可用 artifact_type = target.extra.get("artifact_type", "Code") if artifact_type == "Container": import subprocess + try: result = subprocess.run(["docker", "version"], capture_output=True, timeout=10) if result.returncode != 0: @@ -341,14 +338,19 @@ async def validate_config(self, target: DeployTarget) -> tuple[bool, str]: return True, "" - async def package(self, project_dir: str, detection_result: Any, config: Dict[str, Any] = None) -> PackageInfo: + async def package( + self, + project_dir: str, + detection_result: Any, + config: Optional[Dict[str, Any]] = None, + ) -> PackageInfo: """打包项目 - + Code 模式: 返回基本 PackageInfo,实际构建在 build() 中进行 Container 模式: 使用 ContainerBuilder 打包 """ project_path = Path(project_dir) - + # 创建基本 PackageInfo package_info = PackageInfo( name=detection_result.name or project_path.name, @@ -356,14 +358,15 @@ async def package(self, project_dir: str, detection_result: Any, config: Dict[st build_dir=str(project_path / ".agentengine" / "build"), project_dir=str(project_path), entry_point=detection_result.entry_point, - metadata={} + metadata={}, ) - + # 尝试加载之前保存的构建元数据 (ks3_path 等) try: metadata_file = project_path / ".agentengine" / "build-metadata.json" if metadata_file.exists(): import json + with open(metadata_file, "r") as f: saved_data = json.load(f) if "metadata" in saved_data: @@ -376,15 +379,32 @@ async def package(self, project_dir: str, detection_result: Any, config: Dict[st click.echo(f" 📦 已加载上次构建元数据: {count} 项") except Exception as e: click.secho(f"⚠️ 加载构建元数据失败: {e}", fg="yellow") - - return package_info - + return package_info async def build(self, package_info: PackageInfo, target: DeployTarget) -> PackageInfo: """构建 & 上传 (客户端直传 KS3)""" 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 + if artifact_type == "Code": # 1. 检查是否已有 KS3 路径 # 优先级: CLI传入 > Metadata缓存 (仅当 !no_cache) @@ -392,58 +412,73 @@ async def build(self, package_info: PackageInfo, target: DeployTarget) -> Packag cached_ks3_path = package_info.metadata.get("ks3_path") no_cache = target.extra.get("no_cache", False) repackage = target.extra.get("repackage", False) - + # 如果显式传入了 ks3-path,直接使用 if cli_ks3_path: package_info.metadata["ks3_path"] = cli_ks3_path return package_info - + # 如果没有 no_cache 且有缓存,才使用缓存 if not no_cache and not repackage and cached_ks3_path: logger.info(f"Using cached bundle: {cached_ks3_path}") return package_info - # 2. 构建 ZIP 包 (委托给 CodeBuilder) - + # 2. 构建 ZIP 包 + # 传递配置,包括 no_cache builder_config = target.extra.copy() builder_config["no_cache"] = no_cache builder_config["repackage"] = repackage - - # 实例化 Builder - # 注意: CodeBuilder 目前设计为直接操作 project_dir, - # 这里传入原始 project_dir (package_info.project_dir) - builder = CodeBuilder(Path(package_info.project_dir), config=builder_config) - + + if artifact_type == "ManagedRuntime": + from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder + + 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) + # 执行构建 build_result = builder.build() - + if not build_result.success: raise Exception(f"构建失败: {build_result.error_message}") - + zip_path = build_result.artifact_path + if zip_path is None: + raise RuntimeError("代码构建成功但未生成 artifact_path") + package_info.metadata.update(build_result.metadata) + if build_result.metadata.get("manifest_sha256"): + target.extra["manifest_sha256"] = build_result.metadata["manifest_sha256"] # click.echo(f" ✅ ZIP 已生成: {zip_path}") - + # 3. 直接上传 KS3 (使用本地 AK/SK) # 3. 直接上传 KS3 (使用本地 AK/SK) - click.echo("\n正在上传代码包到 KS3...") + artifact_label = "Runtime manifest" if artifact_type == "ManagedRuntime" else "代码包" + click.echo(f"\n正在上传{artifact_label}到 KS3...") upload_started_at = time.monotonic() - + ks3_bucket = target.extra.get("ks3_bucket") upload_region = "cn-beijing-6" if target.region == "pre-online" else target.region - + uploader = KS3Uploader(region=upload_region, bucket=ks3_bucket) - + # 使用时间戳确保每次上传的代码包路径唯一,支持真正的版本回滚 from datetime import datetime + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - object_key = f"agents/{package_info.name}/code_{timestamp}.zip" - + object_prefix = "runtime" if artifact_type == "ManagedRuntime" else "code" + object_key = f"agents/{package_info.name}/{object_prefix}_{timestamp}.zip" + ks3_path = await uploader.upload(zip_path, object_key) - + if not ks3_path: raise Exception("KS3 上传失败") - + logger.info(f"Upload success: {ks3_path}") click.echo(f" ✓ 上传耗时: {self._format_elapsed(upload_started_at)}") package_info.metadata["ks3_path"] = ks3_path @@ -466,45 +501,47 @@ async def build(self, package_info: PackageInfo, target: DeployTarget) -> Packag logger.info(f"Using cached image: {cached_image}") package_info.image = cached_image return package_info - - builder = ContainerBuilder( + + container_builder = ContainerBuilder( project_dir=Path(package_info.project_dir), tag=tag, registry=registry, - no_cache=no_cache + no_cache=no_cache, ) - + # 构建镜像 - build_result = builder.build() + build_result = container_builder.build() if not build_result.success: raise Exception(f"镜像构建失败: {build_result.error_message}") - - image_name = build_result.metadata.get("image") + + image_name = str(build_result.metadata.get("image") or "").strip() + if not image_name: + raise RuntimeError("容器构建成功但未生成 image") package_info.image = image_name package_info.metadata["image"] = image_name - + # 推送镜像 - if not builder.push(image_name): + if not container_builder.push(image_name): raise Exception("镜像推送失败") - + click.secho(f"✅ 镜像已推送: {image_name}", fg="green") self._persist_build_metadata(package_info) - + return package_info async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> DeployResult: """通过 AgentEngine Server 部署 - + 逻辑: - 读取本地 .agentengine.state 文件获取 agent_id - 如果有 agent_id → 执行更新 (endpoint 不变) - 如果没有 → 创建新 Agent,保存 agent_id 到状态文件 """ - + server_url = os.getenv("AGENTENGINE_SERVER_URL") click.echo(f"\n🚀 开始部署到 Serverless (Managed by {server_url})...") - + # 读取本地状态文件 project_dir = package_info.project_dir state_file = Path(project_dir) / ".agentengine.state" @@ -527,26 +564,29 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo ui_state["ui_bundle_path"] = project_config.get("ui_bundle_path") if project_config.get("ui_bundle_dir") and not ui_state.get("ui_bundle_dir"): ui_state["ui_bundle_dir"] = project_config.get("ui_bundle_dir") - + # 构造请求 payload artifact_type = target.extra.get("artifact_type", "Code") artifact_path = "" - if artifact_type == "Code": + code_backed = artifact_type == "Code" + if code_backed: original_artifact_path = package_info.metadata.get("ks3_path", "") artifact_path = original_artifact_path - + # 转换为内网地址 (如果 serverless 无法解析 ks3://) if artifact_path.startswith("ks3://"): try: from ksadk.common.constants import get_ks3_endpoints - + # 确定 KS3 Region code (pre-online -> cn-beijing) ks3_region = target.extra.get("ks3_region") if not ks3_region: - ks3_region = "cn-beijing-6" if target.region == "pre-online" else target.region - + ks3_region = ( + "cn-beijing-6" if target.region == "pre-online" else target.region + ) + _, internal_endpoint = get_ks3_endpoints(ks3_region) - + if internal_endpoint: # ks3://bucket/key -> http://bucket.internal_endpoint/key bucket, key = self._parse_code_artifact_path(artifact_path) @@ -555,32 +595,39 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo click.echo(f" Converted artifact path: {artifact_path}") except Exception as e: logger.warning(f"Failed to convert ks3 path to internal URL: {e}") - + if not artifact_path: raise ValueError( - "❌ 未找到代码包路径 (ks3_path)。\n" - " 在 Serverless 模式下,必须先上传代码包。\n" - " 👉 请先执行: agentengine build --mode code --push\n" + "❌ 未找到 KS3 artifact 路径 (ks3_path)。\n" + " 在 Serverless 模式下,必须先上传构建产物。\n" + " 👉 请先执行: agentengine build --push\n" " 或者在 deploy 命令中使用 --ks3-path 指定路径。" ) - parsed_bucket, parsed_object_key = self._parse_code_artifact_path(original_artifact_path) + parsed_bucket, parsed_object_key = self._parse_code_artifact_path( + original_artifact_path + ) if not parsed_bucket or not parsed_object_key: raise ValueError( - "❌ ks3_path 格式无效,缺少代码包对象路径。\n" + "❌ ks3_path 格式无效,缺少 artifact 对象路径。\n" f" 当前值: {original_artifact_path or '(empty)'}\n" " 期望格式: ks3:///\n" - " 👉 请重新执行: agentengine build --mode code --push\n" + " 👉 请重新执行: agentengine build --push\n" " 或者传入完整的 --ks3-path。" ) - - else: - artifact_path = package_info.image - + + elif artifact_type == "Container": + artifact_path = str(package_info.image or "").strip() + if not artifact_path: + raise ValueError("Container 部署缺少 image") + elif artifact_type != "ManagedRuntime": + raise ValueError(f"Unsupported artifact type: {artifact_type}") + # 构建 KS3 凭证 ks3_config = None - if artifact_type == "Code": + if code_backed: from ksadk.common.auth import AWSV4Auth + auth = AWSV4Auth() # 读取本地 AK/SK if auth.access_key_id and auth.secret_access_key: # 智能推断 Bucket 和 Region @@ -589,7 +636,7 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo bucket_name, _ = self._parse_code_artifact_path( package_info.metadata.get("ks3_path", "") ) - + # 如果没有提供,尝试从 artifact_path 解析 if not bucket_name: if artifact_path.startswith("ks3://"): @@ -599,35 +646,38 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo logger.info(f"Extracted bucket from ks3:// path: {bucket_name}") except IndexError: pass - elif artifact_path.startswith("http") and "." in artifact_path: + elif artifact_path.startswith("http") and "." in artifact_path: try: # http://bucket.endpoint/key -> bucket from urllib.parse import urlparse + parsed = urlparse(artifact_path) bucket_name = parsed.netloc.split(".")[0] logger.info(f"Extracted bucket from HTTP URL: {bucket_name}") except Exception as e: logger.warning(f"Failed to extract bucket from URL: {e}") - + # 如果仍然没有,使用智能默认值(与 KS3Uploader 逻辑一致) if not bucket_name: account_id = os.getenv("KSYUN_ACCOUNT_ID") - upload_region = "cn-beijing-6" if target.region == "pre-online" else target.region - + upload_region = ( + "cn-beijing-6" if target.region == "pre-online" else target.region + ) + if not account_id: raise ValueError( "❌ 缺少 KSYUN_ACCOUNT_ID 环境变量\n" " Bucket 名称格式必须为: agentengine-{account_id}-{region}\n" " 请在 .env 文件中设置: KSYUN_ACCOUNT_ID=你的账号ID" ) - + bucket_name = f"agentengine-{account_id}-{upload_region}" logger.info(f"Using default bucket: {bucket_name}") - + # Region 逻辑需与 Build 阶段保持一致 (pre-online -> cn-beijing-6) ks3_region = target.extra.get("ks3_region") if not ks3_region: - ks3_region = "cn-beijing-6" if target.region == "pre-online" else target.region + ks3_region = "cn-beijing-6" if target.region == "pre-online" else target.region ks3_config = { "access_key": auth.access_key_id, @@ -636,18 +686,31 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo "bucket": bucket_name, } logger.info(f"📦 KS3 Config: bucket={bucket_name}, region={ks3_region}") - + + runtime_config = None + if artifact_type == "ManagedRuntime": + runtime_config = { + "name": str(target.extra.get("runtime_name") or "").strip(), + "version": str(target.extra.get("runtime_version") or "").strip(), + } + missing = [key for key, value in runtime_config.items() if not value] + if missing: + raise ValueError( + "ManagedRuntime 部署缺少 " + + ", ".join(f"runtime_config.{key}" for key in missing) + ) + try: # 获取 dry_run 标识 is_dry_run = target.extra.get("dry_run", False) async with AgentEngineClient(region=target.region, dry_run=is_dry_run) as client: agent_exists = False - + if existing_agent_id: # 有本地状态 → 先检查服务器上是否存在 click.echo(f" 检测到本地状态: {existing_agent_id}") - + try: # 尝试获取 agent,确认是否存在 existing_agent = await client.get_agent(existing_agent_id) @@ -657,32 +720,37 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo # Agent 不存在或查询失败 err_msg = str(e).lower() if "not found" in err_msg or "404" in err_msg or "不存在" in err_msg: - click.secho(f" ⚠️ 服务器上未找到 Agent {existing_agent_id},将创建新 Agent", fg="yellow") + click.secho( + f" ⚠️ 服务器上未找到 Agent {existing_agent_id},将创建新 Agent", + fg="yellow", + ) agent_exists = False - # 如果是 DryRun 抛出的异常(表明请求本来会发出去但被拦截了),我们认为 Agent 可能存在也可能不存在 - # 但为了安全起见,在 DryRun 模式下我们假设它存在并走更新路径,或者简单地打印日志 + # DryRun 异常表示真实请求被拦截,无法确认 Agent 是否存在。 + # 为安全起见,DryRun 假设它存在并走更新路径。 elif "Dry Run" in str(e): - click.secho(f" [Dry Run] 假设 Agent {existing_agent_id} 存在", fg="cyan") - agent_exists = True + click.secho( + f" [Dry Run] 假设 Agent {existing_agent_id} 存在", fg="cyan" + ) + agent_exists = True else: # 其他错误,重新抛出 raise - + if agent_exists: # Agent 存在 → 执行更新 - click.echo(f" 执行热更新 (endpoint 保持不变)...") - + click.echo(" 执行热更新 (endpoint 保持不变)...") + update_data = { "artifact_type": artifact_type, "artifact_path": artifact_path, "resources": { "cpu": target.resources.cpu, - "memory": target.resources.memory + "memory": target.resources.memory, }, "scaling": { "min_replicas": target.scaling.min_replicas, "max_replicas": target.scaling.max_replicas, - "concurrency": target.scaling.concurrency + "concurrency": target.scaling.concurrency, }, "observability": { "langfuse_enabled": target.extra.get("enable_observability", True) @@ -693,16 +761,20 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo "url": resolved_ui.url, }, } - + if runtime_config: + update_data["runtime_config"] = runtime_config + if ks3_config: update_data["ks3"] = ks3_config elif artifact_type == "Container": image_credential = self._image_credential_from_env(artifact_path) if image_credential: update_data["image_credential"] = image_credential - click.echo( - f" 🔑 镜像凭证: {image_credential['username']}@{image_credential['endpoint']}" + credential_target = ( + f"{image_credential['username']}@" + f"{image_credential['endpoint']}" ) + click.echo(f" 🔑 镜像凭证: {credential_target}") # 加载全局配置 + 本地 .env,并通过部署参数注入到运行时环境变量。 env_vars, env_file_exists, project_env_count = self._load_deploy_env_vars( @@ -727,17 +799,19 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo storage_config = self._serialize_storage_config(target) if storage_config: update_data["storage"] = storage_config - + # 注入更新时间戳,强制触发 Rolling Update (Pod 重启) if "env_vars" not in update_data: update_data["env_vars"] = {} - + import time + update_data["env_vars"]["KSADK_UPDATED_AT"] = str(int(time.time())) - + # DEBUG: 打印 Payload 确认 trigger 是否存在 - click.echo(f" 🔄 更新 Trigger: KSADK_UPDATED_AT={update_data['env_vars']['KSADK_UPDATED_AT']}") - + updated_at_value = update_data["env_vars"]["KSADK_UPDATED_AT"] + click.echo(f" 🔄 更新 Trigger: KSADK_UPDATED_AT={updated_at_value}") + res = await client.update_agent(existing_agent_id, update_data) latest_access = {} if not is_dry_run: @@ -751,38 +825,40 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo exc, ), ) - + # 如果是 Dry Run,手动构造假响应以避免崩溃 if is_dry_run and not res: res = {"name": package_info.name, "endpoint": "http://dry-run-endpoint"} - + # 更新本地状态 (保留旧字段如 api_key) new_state = local_state.copy() - new_state.update({ - "agent_id": latest_access.get("agent_id") or existing_agent_id, - "name": latest_access.get("name") or res.get("name"), - "region": target.region, - "endpoint": latest_access.get("endpoint") or res.get("endpoint"), - "updated_at": self._now_iso(), - **ui_state, - }) + new_state.update( + { + "agent_id": latest_access.get("agent_id") or existing_agent_id, + "name": latest_access.get("name") or res.get("name"), + "region": target.region, + "endpoint": latest_access.get("endpoint") or res.get("endpoint"), + "updated_at": self._now_iso(), + **ui_state, + } + ) if latest_access.get("api_key"): new_state["api_key"] = latest_access["api_key"] self._save_state(state_file, new_state) - + return DeployResult( - status=DeployStatus.DEPLOYING, + status=DeployStatus.DEPLOYING, agent_id=existing_agent_id, agent_name=res.get("name"), endpoint=res.get("endpoint"), - message=f"✅ Agent 已更新: {existing_agent_id}" + message=f"✅ Agent 已更新: {existing_agent_id}", ) # else: agent_exists = False,继续执行创建逻辑 - + # 没有本地状态,或本地状态对应的 Agent 在服务器上不存在 → 创建新 Agent if not existing_agent_id or not agent_exists: click.echo(f" 创建新 Agent: {package_info.name}") - + request_data = { "name": package_info.name, "framework": package_info.framework, @@ -792,12 +868,12 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo "instance_id": target.extra.get("instance_id", "default"), "resources": { "cpu": target.resources.cpu, - "memory": target.resources.memory + "memory": target.resources.memory, }, "scaling": { "min_replicas": target.scaling.min_replicas, "max_replicas": target.scaling.max_replicas, - "concurrency": target.scaling.concurrency + "concurrency": target.scaling.concurrency, }, "observability": { "langfuse_enabled": target.extra.get("enable_observability", True) @@ -808,7 +884,9 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo "url": resolved_ui.url, }, } - + if runtime_config: + request_data["runtime_config"] = runtime_config + if ks3_config: request_data["ks3"] = ks3_config @@ -817,9 +895,10 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo image_credential = self._image_credential_from_env(artifact_path) if image_credential: request_data["image_credential"] = image_credential - click.echo( - f" 🔑 镜像凭证: {image_credential['username']}@{image_credential['endpoint']}" + credential_target = ( + f"{image_credential['username']}@" f"{image_credential['endpoint']}" ) + click.echo(f" 🔑 镜像凭证: {credential_target}") # 加载全局配置 + 本地 .env,并通过部署参数注入到运行时环境变量。 env_vars, env_file_exists, project_env_count = self._load_deploy_env_vars( @@ -837,7 +916,7 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo click.echo(f" 📦 加载环境变量: {len(env_vars)} 项 from 全局配置") if env_vars: - request_data["env_vars"] = env_vars + request_data["env_vars"] = env_vars network_config = self._serialize_network_config(target, is_update=False) if network_config: @@ -852,20 +931,22 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo ksyun_account_id = os.getenv("KSYUN_ACCOUNT_ID") if ksyun_account_id: extra_headers["X-Ksc-Account-Id"] = ksyun_account_id - + # 重新构造一个带 Header 的 client - async with AgentEngineClient(region=target.region, extra_headers=extra_headers, dry_run=is_dry_run) as new_client: + async with AgentEngineClient( + region=target.region, extra_headers=extra_headers, dry_run=is_dry_run + ) as new_client: res = await new_client.create_agent(request_data) - + # 如果是 Dry Run,手动构造假响应 if is_dry_run and not res: res = { - "agent_id": "dry-run-agent-id", - "name": package_info.name, - "endpoint": "http://dry-run-endpoint", - "api_key": "dry-run-key" + "agent_id": "dry-run-agent-id", + "name": package_info.name, + "endpoint": "http://dry-run-endpoint", + "api_key": "dry-run-key", } - + # CreateAgentProduct 返回 order_id,需要轮询 list_agents 获取 agent_id new_agent_id = res.get("agent_id") order_id = res.get("order_id") @@ -876,10 +957,13 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo if order_id and not new_agent_id: click.echo(f" 📋 订单已创建: {order_id},等待实例创建...") import time + for i in range(12): # 最多等 60s time.sleep(5) try: - detail = await client.get_agent(name=package_info.name, include_api_key=True) + detail = await client.get_agent( + name=package_info.name, include_api_key=True + ) qa = detail.get("quick_access", {}) basic = detail.get("basic", {}) new_agent_id = basic.get("agent_id") @@ -894,7 +978,10 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo click.echo(f" ⏳ 等待中... ({(i+1)*5}s)") if not new_agent_id: - click.secho(" ⚠️ 实例仍在创建中,稍后使用 'agentengine status' 查看", fg="yellow") + click.secho( + " ⚠️ 实例仍在创建中,稍后使用 'agentengine status' 查看", + fg="yellow", + ) latest_access = {} if new_agent_id and not is_dry_run: @@ -913,54 +1000,63 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo agent_name = latest_access.get("name") or agent_name agent_endpoint = latest_access.get("endpoint") or agent_endpoint agent_api_key = latest_access.get("api_key") or agent_api_key - + # 保存 agent_id 到本地状态文件 - self._save_state(state_file, { - "agent_id": new_agent_id, - "name": agent_name, - "region": target.region, - "endpoint": agent_endpoint, - "api_key": agent_api_key, # 只在首次保存 - "order_id": order_id, - "created_at": self._now_iso(), - **ui_state, - }) - - click.echo(f" 💾 已保存状态到 .agentengine.state") - + self._save_state( + state_file, + { + "agent_id": new_agent_id, + "name": agent_name, + "region": target.region, + "endpoint": agent_endpoint, + "api_key": agent_api_key, # 只在首次保存 + "order_id": order_id, + "created_at": self._now_iso(), + **ui_state, + }, + ) + + click.echo(" 💾 已保存状态到 .agentengine.state") + return DeployResult( - status=DeployStatus.DEPLOYING, + status=DeployStatus.DEPLOYING, agent_id=new_agent_id, agent_name=agent_name, - endpoint=agent_endpoint, + endpoint=agent_endpoint, api_key=agent_api_key, - message=f"✅ Agent ID: {new_agent_id or '(创建中)'} (首次部署, 订单: {order_id or '-'})" + message=( + f"✅ Agent ID: {new_agent_id or '(创建中)'} " + f"(首次部署, 订单: {order_id or '-'})" + ), ) + return DeployResult( + status=DeployStatus.FAILED, + message="部署流程未返回 Agent 状态", + ) + except DryRunExit as e: return DeployResult( status=DeployStatus.SKIPPED, message="✅ Dry Run Completed: 请求已打印,未执行实际变更。", metadata={"dry_run_request": e.payload or {}}, ) - + except Exception as e: logger.error(f"Deploy failed: {e}") - + # 检测名称冲突 err_msg = str(e) if "Conflict" in err_msg or "409" in err_msg or "already exists" in err_msg: return DeployResult( status=DeployStatus.FAILED, message=f"❌ 部署失败: Agent 名称 '{package_info.name}' 已存在。\n" - f" 提示: 请检查是否重复创建。\n" - f" 👉 解决方法: 请在 agentengine.yaml 中修改 'name' 字段 (如添加后缀) 后重试。" + f" 提示: 请检查是否重复创建。\n" + " 👉 解决方法: 请在 agentengine.yaml 中修改 'name' 字段 " + "(如添加后缀) 后重试。", ) - - return DeployResult( - status=DeployStatus.FAILED, - message=f"Server 请求失败: {str(e)}" - ) + + return DeployResult(status=DeployStatus.FAILED, message=f"Server 请求失败: {str(e)}") async def get_status(self, agent_id: str, target: DeployTarget) -> DeployResult: """获取 Agent 状态""" @@ -968,7 +1064,7 @@ async def get_status(self, agent_id: str, target: DeployTarget) -> DeployResult: try: async with AgentEngineClient(region=target.region, dry_run=dry_run) as client: res = await client.get_agent(agent_id) - + status_map = { "running": DeployStatus.RUNNING, "ready": DeployStatus.RUNNING, @@ -978,9 +1074,9 @@ async def get_status(self, agent_id: str, target: DeployTarget) -> DeployResult: "scaling": DeployStatus.UPDATING, "failed": DeployStatus.FAILED, "error": DeployStatus.FAILED, - "unknown": DeployStatus.UNKNOWN + "unknown": DeployStatus.UNKNOWN, } - + # 使状态匹配不区分大小写,以兼容服务端的改动 (Creating -> CREATING) current_status = (res.get("status") or "").lower() return DeployResult( @@ -988,21 +1084,20 @@ async def get_status(self, agent_id: str, target: DeployTarget) -> DeployResult: agent_id=res.get("agent_id"), agent_name=res.get("name"), endpoint=res.get("endpoint"), - message=f"Status: {res.get('status')} ({res.get('phase')})" + message=f"Status: {res.get('status')} ({res.get('phase')})", ) except DryRunExit: return DeployResult(status=DeployStatus.SKIPPED, message="Dry Run executed.") except Exception as e: - return DeployResult( - status=DeployStatus.UNKNOWN, - message=f"查询失败: {e}" - ) + return DeployResult(status=DeployStatus.UNKNOWN, message=f"查询失败: {e}") async def destroy(self, agent_id: str, target: DeployTarget) -> bool: """销毁 Agent""" dry_run = target.extra.get("dry_run", False) project_dir = str(target.extra.get("project_dir") or "").strip() - state_file = (Path(project_dir).resolve() if project_dir else Path(".").resolve()) / ".agentengine.state" + state_file = ( + Path(project_dir).resolve() if project_dir else Path(".").resolve() + ) / ".agentengine.state" try: async with AgentEngineClient(region=target.region, dry_run=dry_run) as client: @@ -1015,8 +1110,10 @@ async def destroy(self, agent_id: str, target: DeployTarget) -> bool: os.remove(state_file) logger.info(f"Deleted local state file: {state_file}") except Exception as cleanup_error: - logger.warning(f"Failed to delete local state file {state_file}: {cleanup_error}") - return success + logger.warning( + f"Failed to delete local state file {state_file}: {cleanup_error}" + ) + return bool(success) except DryRunExit: # 让异常冒泡给 CLI 处理 raise @@ -1030,19 +1127,27 @@ async def list_agents(self, target: DeployTarget) -> List[DeployResult]: try: async with AgentEngineClient(region=target.region, dry_run=dry_run) as client: res = await client.list_agents() - + results = [] for agent in res.get("Agents", []): current_status = (agent.get("status") or "").lower() - results.append(DeployResult( - status=DeployStatus.RUNNING if current_status in ("running", "ready") else ( - DeployStatus.DEPLOYING if current_status == "creating" else DeployStatus.UNKNOWN - ), - agent_id=agent.get("agent_id"), - agent_name=agent.get("name"), - endpoint=agent.get("endpoint"), - message=agent.get("status") - )) + results.append( + DeployResult( + status=( + DeployStatus.RUNNING + if current_status in ("running", "ready") + else ( + DeployStatus.DEPLOYING + if current_status == "creating" + else DeployStatus.UNKNOWN + ) + ), + agent_id=agent.get("agent_id"), + agent_name=agent.get("name"), + endpoint=agent.get("endpoint"), + message=agent.get("status"), + ) + ) return results except DryRunExit: raise @@ -1056,7 +1161,7 @@ async def invoke(self, agent_id: str, message: str, target: DeployTarget) -> str try: async with AgentEngineClient(region=target.region, dry_run=dry_run) as client: response = await client.chat(agent_id, message, stream=False) - return response.get("output", "") + return str(response.get("output", "")) except DryRunExit: raise except Exception as e: @@ -1066,7 +1171,7 @@ def _load_state(self, state_file: Path) -> Dict[str, Any]: """读取本地状态文件""" if state_file.exists(): try: - with open(state_file, 'r', encoding='utf-8') as f: + with open(state_file, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} except Exception as e: logger.warning(f"Failed to load state file: {e}") @@ -1102,8 +1207,9 @@ def _merge_ui_runtime_state( def _save_state(self, state_file: Path, state: Dict[str, Any]) -> None: """保存状态到本地文件""" import yaml + try: - with open(state_file, 'w', encoding='utf-8') as f: + with open(state_file, "w", encoding="utf-8") as f: yaml.dump(state, f, default_flow_style=False, allow_unicode=True) logger.debug(f"State saved to {state_file}") except Exception as e: @@ -1112,4 +1218,5 @@ def _save_state(self, state_file: Path, state: Dict[str, Any]) -> None: def _now_iso(self) -> str: """返回当前时间 ISO 格式""" from datetime import datetime + return datetime.now().isoformat() diff --git a/ksadk/deployment/registry.py b/ksadk/deployment/registry.py index 5f4f123d..0ddd1720 100644 --- a/ksadk/deployment/registry.py +++ b/ksadk/deployment/registry.py @@ -4,115 +4,121 @@ 使用装饰器模式自动注册 Provider,支持动态扩展。 """ -from typing import Dict, Type, List, Optional +from typing import Dict, List, Optional, Type + from ksadk.deployment.base import BaseDeployProvider class DeployProviderRegistry: """Provider 注册表 - + 使用方式: @DeployProviderRegistry.register("my-provider") class MyProvider(BaseDeployProvider): ... """ - + _providers: Dict[str, Type[BaseDeployProvider]] = {} _instances: Dict[str, BaseDeployProvider] = {} - + @classmethod def register(cls, name: str): """装饰器: 注册 Provider - + Args: name: Provider 名称 (如 "serverless", "k8s", "docker") """ + def decorator(provider_class: Type[BaseDeployProvider]): if not issubclass(provider_class, BaseDeployProvider): raise TypeError(f"{provider_class} must inherit from BaseDeployProvider") - + provider_class.name = name cls._providers[name] = provider_class return provider_class - + return decorator - + @classmethod - def get(cls, name: str, config: Dict = None) -> BaseDeployProvider: + def get(cls, name: str, config: Optional[Dict] = None) -> BaseDeployProvider: """获取 Provider 实例 - + Args: name: Provider 名称 config: Provider 配置 - + Returns: Provider 实例 """ if name not in cls._providers: available = ", ".join(cls._providers.keys()) raise ValueError(f"Unknown provider: {name}. Available: {available}") - + # 缓存实例 (相同 name 返回相同实例) cache_key = f"{name}:{hash(str(config))}" if cache_key not in cls._instances: cls._instances[cache_key] = cls._providers[name](config) - + return cls._instances[cache_key] - + @classmethod def get_class(cls, name: str) -> Type[BaseDeployProvider]: """获取 Provider 类 (不实例化) - + Args: name: Provider 名称 - + Returns: Provider 类 """ if name not in cls._providers: raise ValueError(f"Unknown provider: {name}") return cls._providers[name] - + @classmethod def list_providers(cls) -> List[str]: """列出所有已注册的 Provider 名称 - + Returns: Provider 名称列表 """ return list(cls._providers.keys()) - + @classmethod def get_provider_info(cls) -> List[Dict]: """获取所有 Provider 的详细信息 - + Returns: Provider 信息列表 """ result = [] for name, provider_class in cls._providers.items(): - result.append({ - "name": name, - "display_name": getattr(provider_class, "display_name", name), - "description": getattr(provider_class, "description", ""), - "supports_streaming": getattr(provider_class, "supports_streaming", False), - "supports_scaling": getattr(provider_class, "supports_scaling", False), - "requires_image_registry": getattr(provider_class, "requires_image_registry", False), - }) + result.append( + { + "name": name, + "display_name": getattr(provider_class, "display_name", name), + "description": getattr(provider_class, "description", ""), + "supports_streaming": getattr(provider_class, "supports_streaming", False), + "supports_scaling": getattr(provider_class, "supports_scaling", False), + "requires_image_registry": getattr( + provider_class, "requires_image_registry", False + ), + } + ) return result - + @classmethod def has_provider(cls, name: str) -> bool: """检查 Provider 是否存在 - + Args: name: Provider 名称 - + Returns: 是否存在 """ return name in cls._providers - + @classmethod def clear(cls): """清空注册表 (主要用于测试)""" diff --git a/ksadk/deployment/state.py b/ksadk/deployment/state.py index 62b38b7f..4762f869 100644 --- a/ksadk/deployment/state.py +++ b/ksadk/deployment/state.py @@ -4,11 +4,11 @@ 用于管理本地 .agentengine.state 文件,记录已部署的 Agent/MCP 信息。 """ -import yaml -from pathlib import Path -from typing import Optional, Dict, Any from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional +import yaml STATE_FILE_NAME = ".agentengine.state" @@ -23,9 +23,9 @@ def load_state(project_dir: Path) -> Dict[str, Any]: state_file = get_state_file_path(project_dir) if not state_file.exists(): return {} - + try: - with open(state_file, 'r', encoding='utf-8') as f: + with open(state_file, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} except Exception: return {} @@ -34,12 +34,12 @@ def load_state(project_dir: Path) -> Dict[str, Any]: def save_state(project_dir: Path, data: Dict[str, Any]) -> None: """保存状态文件""" state_file = get_state_file_path(project_dir) - + # 自动添加 updated_at if "updated_at" not in data: data["updated_at"] = datetime.now().isoformat() - - with open(state_file, 'w', encoding='utf-8') as f: + + with open(state_file, "w", encoding="utf-8") as f: yaml.dump(data, f, default_flow_style=False, allow_unicode=True) @@ -51,9 +51,9 @@ def update_state(project_dir: Path, updates: Dict[str, Any]) -> Dict[str, Any]: return current_state -def clear_state(project_dir: Path, key: str = None) -> bool: +def clear_state(project_dir: Path, key: Optional[str] = None) -> bool: """清理状态文件 - + Args: project_dir: 项目目录 key: 如果指定,仅当状态中的 ID 与 key 匹配时才删除 @@ -64,7 +64,7 @@ def clear_state(project_dir: Path, key: str = None) -> bool: state_file = get_state_file_path(project_dir) if not state_file.exists(): return False - + if key: # 检查是否匹配 state = load_state(project_dir) diff --git a/ksadk/deployment/ui_config.py b/ksadk/deployment/ui_config.py index 4cdde1fc..4916bff5 100644 --- a/ksadk/deployment/ui_config.py +++ b/ksadk/deployment/ui_config.py @@ -2,13 +2,13 @@ from ksadk.ui_config import ( # noqa: F401 SUPPORTED_UI_PROFILES, - UIConfig, UI_PROFILE_ADK, UI_PROFILE_AUTO, UI_PROFILE_CUSTOM, UI_PROFILE_HERMES, UI_PROFILE_LANGCHAIN, UI_PROFILE_OPENCLAW, + UIConfig, default_ui_path, extract_ui_state, infer_ui_profile_from_framework, diff --git a/ksadk/detection/__init__.py b/ksadk/detection/__init__.py index 4e427489..f8468bd1 100644 --- a/ksadk/detection/__init__.py +++ b/ksadk/detection/__init__.py @@ -2,6 +2,6 @@ KsADK Detection - 框架自动检测模块 """ -from ksadk.detection.detector import FrameworkDetector, DetectionResult, FrameworkType +from ksadk.detection.detector import DetectionResult, FrameworkDetector, FrameworkType __all__ = ["FrameworkDetector", "DetectionResult", "FrameworkType"] diff --git a/ksadk/detection/detector.py b/ksadk/detection/detector.py index a50baa8e..5495d99e 100644 --- a/ksadk/detection/detector.py +++ b/ksadk/detection/detector.py @@ -4,7 +4,7 @@ import ast import json -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Optional @@ -20,6 +20,8 @@ class FrameworkType(Enum): LANGGRAPH = "langgraph" DEEPAGENTS = "deepagents" HERMES = "hermes" + FASTMCP = "fastmcp" + CODEX = "codex" UNKNOWN = "unknown" @@ -34,6 +36,9 @@ class DetectionResult: agent_variable: str = "root_agent" runner_class: str = "" confidence: float = 0.0 + # 配置文件的原始内容(ksadk.yaml/agentengine.yaml),供 runner 读 model/prompt 等 + # 真实生效字段(codex 无 root_agent,配置全靠它)。默认空 dict,不影响既有构造。 + raw_config: dict = field(default_factory=dict) @property def is_valid(self) -> bool: @@ -44,6 +49,14 @@ class FrameworkDetector: """框架检测器""" _ENTRY_FILES = ("agent.py", "main.py", "app.py") + _LANGCHAIN_MODULES = frozenset( + { + "langchain", + "langchain_openai", + "langchain_core", + "langchain_community", + } + ) def __init__(self, project_dir: str): self.project_dir = Path(project_dir).resolve() @@ -56,6 +69,11 @@ def detect(self) -> DetectionResult: if config_result: return config_result + # 1.5 codex.yaml 标志文件(fallback 自动探测,优先级低于 ksadk.yaml framework) + codex_result = self._check_codex_yaml() + if codex_result: + return codex_result + graph_config_result = self._check_langgraph_json() if graph_config_result: return graph_config_result @@ -104,12 +122,15 @@ def _check_config(self) -> Optional[DetectionResult]: "langgraph": FrameworkType.LANGGRAPH, "deepagents": FrameworkType.DEEPAGENTS, "hermes": FrameworkType.HERMES, + "codex": FrameworkType.CODEX, }.get(framework, FrameworkType.UNKNOWN) artifact_type = str(config.get("artifact_type") or "").strip().lower() is_hermes_container = ( framework_type == FrameworkType.HERMES and artifact_type == "container" ) + # codex 无 root_agent 变量(agent 逻辑在 prompt),跳过 entry_exposes_variable 校验 + is_codex = framework_type == FrameworkType.CODEX default_entry_point = ( "runtime/app.py" if is_hermes_container and (self.project_dir / "runtime" / "app.py").is_file() @@ -119,10 +140,13 @@ def _check_config(self) -> Optional[DetectionResult]: agent_variable = config.get("agent_variable", "root_agent") runner_class = str(config.get("runner_class") or "").strip() entry_path = self.project_dir / str(entry_point).replace("\\", "/") - if not entry_path.exists() or not entry_path.is_file(): + # codex 无 agent.py(逻辑在 prompt),容忍 entry_point 不存在 + if not is_codex and (not entry_path.exists() or not entry_path.is_file()): return None - if not is_hermes_container and not self._entry_exposes_variable( - entry_path, agent_variable + if ( + not is_codex + and not is_hermes_container + and not self._entry_exposes_variable(entry_path, agent_variable) ): return None package = str(config.get("package") or "").strip() @@ -141,10 +165,30 @@ def _check_config(self) -> Optional[DetectionResult]: agent_variable=agent_variable, runner_class=runner_class, confidence=1.0, + raw_config=config, ) except Exception: return None + def _check_codex_yaml(self) -> Optional[DetectionResult]: + """fallback:项目根有 codex.yaml 即判 CODEX(最小标志文件,可含 model/sandbox)。 + + 保守规则,不做源码 import 扫描(避免误判普通 Python 项目)。优先级低于 + ksadk.yaml framework: codex 显式声明。 + """ + codex_path = self.project_dir / "codex.yaml" + if not codex_path.exists(): + return None + return DetectionResult( + type=FrameworkType.CODEX, + name=self.project_dir.name, + entry_point="", + package_path=str(self.project_dir), + agent_variable="", + runner_class="", + confidence=0.9, + ) + def _find_package_dir(self) -> Optional[Path]: """查找 Python 包目录""" # 优先查找与项目同名的包 (下划线版本) @@ -270,19 +314,16 @@ def _framework_from_entry( ) -> FrameworkType: try: content = entry_path.read_text(encoding="utf-8-sig") + except Exception: + return fallback + try: tree = ast.parse(content, filename=str(entry_path)) imports = self._extract_imports(tree) + calls = self._extract_call_names(tree) + framework = self._classify(imports, calls, content) except Exception: - return fallback - if self._is_deepagents(imports, content): - return FrameworkType.DEEPAGENTS - if self._is_langgraph(imports, content): - return FrameworkType.LANGGRAPH - if self._is_adk(imports, content): - return FrameworkType.ADK - if self._is_langchain(imports, content): - return FrameworkType.LANGCHAIN - return fallback + framework = self._classify_from_strings(content) + return framework if framework != FrameworkType.UNKNOWN else fallback def _check_langgraph_json(self) -> Optional[DetectionResult]: config_path = self.project_dir / "langgraph.json" @@ -318,71 +359,79 @@ def _check_langgraph_json(self) -> Optional[DetectionResult]: def _analyze_code(self, agent_file: Path, package_path: Path) -> DetectionResult: """分析代码确定框架类型""" + entry_point = str(agent_file.relative_to(self.project_dir)) try: # 使用 utf-8-sig 自动处理 BOM,兼容 Windows/编辑器带 BOM 的脚本 content = agent_file.read_text(encoding="utf-8-sig") - tree = ast.parse(content, filename=str(agent_file)) except Exception: return DetectionResult( type=FrameworkType.UNKNOWN, name=self.project_dir.name, - entry_point=str(agent_file.relative_to(self.project_dir)), - package_path=str(package_path), - ) - - imports = self._extract_imports(tree) - - # 检测 ADK - if self._is_adk(imports, content): - return DetectionResult( - type=FrameworkType.ADK, - name=package_path.name, - entry_point=str(agent_file.relative_to(self.project_dir)), - package_path=str(package_path), - agent_variable="root_agent", - confidence=0.9, - ) - - # 检测 DeepAgents (LangChain 生态, 底层基于 LangGraph) - if self._is_deepagents(imports, content): - return DetectionResult( - type=FrameworkType.DEEPAGENTS, - name=package_path.name, - entry_point=str(agent_file.relative_to(self.project_dir)), - package_path=str(package_path), - agent_variable="root_agent", - confidence=0.9, - ) - - # 检测 LangGraph - if self._is_langgraph(imports, content): - return DetectionResult( - type=FrameworkType.LANGGRAPH, - name=package_path.name, - entry_point=str(agent_file.relative_to(self.project_dir)), + entry_point=entry_point, package_path=str(package_path), - agent_variable="root_agent", - confidence=0.9, ) - # 检测 LangChain - if self._is_langchain(imports, content): - return DetectionResult( - type=FrameworkType.LANGCHAIN, - name=package_path.name, - entry_point=str(agent_file.relative_to(self.project_dir)), - package_path=str(package_path), - agent_variable="root_agent", - confidence=0.8, - ) + calls: set = set() + try: + tree = ast.parse(content, filename=str(agent_file)) + imports = self._extract_imports(tree) + calls = self._extract_call_names(tree) + framework = self._classify(imports, calls, content) + except Exception: + # AST 解析失败(语法错误等)时退化为字符串判定,避免直接判 UNKNOWN + framework = self._classify_from_strings(content) + name = package_path.name if framework != FrameworkType.UNKNOWN else self.project_dir.name return DetectionResult( - type=FrameworkType.UNKNOWN, - name=self.project_dir.name, - entry_point=str(agent_file.relative_to(self.project_dir)), + type=framework, + name=name, + entry_point=entry_point, package_path=str(package_path), + agent_variable="root_agent", + confidence=self._confidence_for(framework, calls), ) + def _classify(self, imports: set, calls: set, content: str) -> FrameworkType: + """AST 级统一分类,顺序: + ADK → DeepAgents → LangChain(高层) → LangGraph → LangChain(legacy) → UNKNOWN""" + if self._is_adk(imports, content): + return FrameworkType.ADK + if self._is_deepagents(imports, calls, content): + return FrameworkType.DEEPAGENTS + if self._is_langchain_high_level(imports, calls): + return FrameworkType.LANGCHAIN + if self._is_langgraph(calls): + return FrameworkType.LANGGRAPH + if self._is_langchain_legacy(imports, calls): + return FrameworkType.LANGCHAIN + return FrameworkType.UNKNOWN + + def _classify_from_strings(self, content: str) -> FrameworkType: + """AST 解析失败时的字符串兜底,顺序与 _classify 保持一致""" + if "google.adk" in content or "from google.adk" in content: + return FrameworkType.ADK + if "deepagents" in content or "create_deep_agent(" in content: + return FrameworkType.DEEPAGENTS + if "create_agent(" in content: + return FrameworkType.LANGCHAIN + if ( + "StateGraph" in content + or "langgraph.graph" in content + or "create_react_agent(" in content + ): + return FrameworkType.LANGGRAPH + if any(m in content for m in self._LANGCHAIN_MODULES): + return FrameworkType.LANGCHAIN + return FrameworkType.UNKNOWN + + def _confidence_for(self, framework: FrameworkType, calls: set) -> float: + if framework == FrameworkType.UNKNOWN: + return 0.0 + if framework == FrameworkType.LANGCHAIN: + # 高层(create_agent 调用) 0.9,legacy 兜底 0.8 + return 0.9 if "create_agent" in calls else 0.8 + return 0.9 + def _extract_imports(self, tree: ast.AST) -> set: """提取导入的模块""" imports = set() @@ -395,6 +444,19 @@ def _extract_imports(self, tree: ast.AST) -> set: imports.add(node.module.split(".")[0]) return imports + def _extract_call_names(self, tree: ast.AST) -> set: + """提取代码中实际调用的函数/方法名(AST 级,避免注释/字符串误判)""" + calls = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name): + calls.add(func.id) + elif isinstance(func, ast.Attribute): + calls.add(func.attr) + return calls + def _is_adk(self, imports: set, content: str) -> bool: """检测是否为 ADK 项目""" # 检查 google.adk 导入 @@ -402,30 +464,31 @@ def _is_adk(self, imports: set, content: str) -> bool: return True return False - def _is_langgraph(self, imports: set, content: str) -> bool: - """检测是否为 LangGraph 项目""" - if "langgraph" in imports: - return True - if "StateGraph" in content or "langgraph.graph" in content: - return True - return False + def _is_langgraph(self, calls: set) -> bool: + """检测是否为 LangGraph 项目:直接编排 StateGraph 或调用 create_react_agent。 + 不再用 `"StateGraph" in content` 字符串包含,避免注释/类型注解误命中 + 导致 deepagents/langchain 塌缩。""" + return "StateGraph" in calls or "create_react_agent" in calls - def _is_langchain(self, imports: set, content: str) -> bool: - """检测是否为 LangChain 项目""" - langchain_modules = { - "langchain", - "langchain_openai", - "langchain_core", - "langchain_community", - } - if langchain_modules & imports: - return True - return False + def _is_langchain_high_level(self, imports: set, calls: set) -> bool: + """新版 LangChain 高层 API:langchain 系 import 且调用 create_agent。""" + if not (self._LANGCHAIN_MODULES & imports): + return False + return "create_agent" in calls + + def _is_langchain_legacy(self, imports: set, calls: set) -> bool: + """LangChain legacy 兜底:langchain 系 import 且无 LangGraph 直接编排 + 信号(覆盖 LCEL 链等旧用法)。""" + if not (self._LANGCHAIN_MODULES & imports): + return False + return "StateGraph" not in calls and "create_react_agent" not in calls - def _is_deepagents(self, imports: set, content: str) -> bool: + def _is_deepagents(self, imports: set, calls: set, content: str) -> bool: """检测是否为 DeepAgents 项目""" if "deepagents" in imports: return True + if "create_deep_agent" in calls: + return True if "from deepagents import" in content or "create_deep_agent(" in content: return True return False diff --git a/ksadk/detection/mcp_detector.py b/ksadk/detection/mcp_detector.py index 8e98b509..bd69cc94 100644 --- a/ksadk/detection/mcp_detector.py +++ b/ksadk/detection/mcp_detector.py @@ -3,12 +3,14 @@ """ import ast -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import List, Optional import yaml +from ksadk.detection.detector import FrameworkType + @dataclass class MCPDetectionResult: @@ -19,17 +21,18 @@ class MCPDetectionResult: entry_point: str package_path: str mcp_variable: str = "mcp" - tools: List[str] = None # 检测到的工具名称 + tools: List[str] = field(default_factory=list) # 检测到的工具名称 confidence: float = 0.0 - def __post_init__(self): - if self.tools is None: - self.tools = [] - @property def is_valid(self) -> bool: return self.is_mcp + @property + def type(self) -> FrameworkType: + """Expose the framework contract expected by shared builders.""" + return FrameworkType.FASTMCP + class MCPDetector: """MCP 检测器 - 检测 FastMCP 项目""" diff --git a/ksadk/events/__init__.py b/ksadk/events/__init__.py new file mode 100644 index 00000000..892f8532 --- /dev/null +++ b/ksadk/events/__init__.py @@ -0,0 +1,25 @@ +"""RuntimeEvent schema (goal-02)。见 :mod:`ksadk.events.runtime_event`。""" + +from ksadk.events.parser import RuntimeEventParser +from ksadk.events.replay import replay_transcript +from ksadk.events.runtime_event import ( + ALL_EVENT_TYPES, + EVENT_PAYLOAD_REQUIRED_KEYS, + SCHEMA_VERSION, + EventPhase, + EventType, + RuntimeEvent, +) +from ksadk.events.store import RuntimeEventStore + +__all__ = [ + "ALL_EVENT_TYPES", + "EVENT_PAYLOAD_REQUIRED_KEYS", + "EventPhase", + "EventType", + "RuntimeEvent", + "RuntimeEventParser", + "RuntimeEventStore", + "replay_transcript", + "SCHEMA_VERSION", +] diff --git a/ksadk/events/parser.py b/ksadk/events/parser.py new file mode 100644 index 00000000..4939c840 --- /dev/null +++ b/ksadk/events/parser.py @@ -0,0 +1,191 @@ +"""共享 RuntimeEvent → transcript parser (goal-12,H2 §3.2 P1-N)。 + +**单实现**:live 增量渲染与 replay 历史回放**共用同一个 parser**,从根上杜绝"两个仓/ +两条路各实现一遍导致的行为漂移"(H2 高风险:历史 replay 行为漂移)。 + +parser 把一串 :class:`RuntimeEvent` 折叠成**确定性** transcript(按事件顺序的 item 列表 ++ run 状态),``to_json`` 输出逐字节稳定(json sort_keys + 有序 item),供 conformance +fixture 断言 live 渲染与 replay 渲染**逐字节一致**。 + +设计要点: + +- text/reasoning:按 ``(invocation_id, phase)`` 分组累积 delta,``completed`` 收尾。 +- tool.call:``begin`` 开工、``end`` 收尾(同名 call_id 配对)。 +- artifact:``created``/``updated`` 按 name 登记/更新版本。 +- run.*:按 invocation 记录最新 run 状态。 +- checkpoint/usage/a2ui/a2a:记为带类型的附加项(保序,不丢事件)。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from ksadk.events.runtime_event import EventType, RuntimeEvent + +#: parser 消费的渲染族(其余事件类型记为 generic 附加项,不丢)。 +_TEXT_TYPES = frozenset({EventType.TEXT_DELTA, EventType.TEXT_COMPLETED}) +_REASONING_TYPES = frozenset({EventType.REASONING_DELTA, EventType.REASONING_COMPLETED}) +_RUN_TYPES = frozenset( + { + EventType.RUN_STARTED, + EventType.RUN_PROGRESS, + EventType.RUN_INTERRUPTED, + EventType.RUN_COMPLETED, + EventType.RUN_FAILED, + EventType.RUN_CANCELED, + } +) + + +class RuntimeEventParser: + """RuntimeEvent → 确定性 transcript 的共享 parser(live/replay 单实现)。""" + + def __init__(self) -> None: + # (invocation_id, phase) -> {"text": str, "final": bool} + self._text: dict[tuple[str, str], dict[str, Any]] = {} + self._reasoning: dict[tuple[str, str], dict[str, Any]] = {} + # call_id -> {"name","detail","done"} + self._tool_calls: dict[str, dict[str, Any]] = {} + # name -> {"version","text"} + self._artifacts: dict[str, dict[str, Any]] = {} + # invocation_id -> 最新 run 状态字符串 + self._run_status: dict[str, str] = {} + # 渲染顺序:text/reasoning/tool/artifact 首次出现的键 + self._order: list[tuple[str, Any]] = [] + # 其他事件(checkpoint/usage/a2ui/a2a)保序附加 + self._extras: list[dict[str, Any]] = [] + + # ---- 增量喂事件(live 与 replay 同一条路径) ---- + + def feed(self, event: RuntimeEvent) -> None: + et = event.event_type + if et in _TEXT_TYPES: + self._feed_text(self._text, "text", event, final=(et == EventType.TEXT_COMPLETED)) + elif et in _REASONING_TYPES: + self._feed_text( + self._reasoning, "reasoning", event, final=(et == EventType.REASONING_COMPLETED) + ) + elif et == EventType.TOOL_CALL_BEGIN: + call_id = str(event.payload.get("call_id") or "") + if call_id: + self._tool_calls[call_id] = { + "name": event.payload.get("name", ""), + "detail": event.payload.get("detail") or {}, + "done": False, + "invocation_id": event.invocation_id, + } + self._order.append(("tool_call", call_id)) + elif et == EventType.TOOL_CALL_END: + call_id = str(event.payload.get("call_id") or "") + if call_id and call_id in self._tool_calls: + self._tool_calls[call_id]["done"] = True + self._tool_calls[call_id]["result"] = event.payload.get("result") + elif et in (EventType.ARTIFACT_CREATED, EventType.ARTIFACT_UPDATED): + name = str(event.payload.get("name") or "artifact") + prev = self._artifacts.get(name, {"version": 0}) + if name not in self._artifacts: + self._order.append(("artifact", name)) + self._artifacts[name] = { + "version": int(event.payload.get("version") or prev["version"] + 1), + "text": str(event.payload.get("text") or ""), + "invocation_id": event.invocation_id, + } + elif et in _RUN_TYPES: + status = str(event.payload.get("status") or et) + self._run_status[event.invocation_id] = status + else: + # checkpoint/usage/a2ui/a2a 等:保序附加,不丢事件。 + self._extras.append( + { + "event_type": et, + "invocation_id": event.invocation_id, + "payload": event.payload, + } + ) + + def _feed_text( + self, + bucket: dict[tuple[str, str], dict[str, Any]], + kind: str, + event: RuntimeEvent, + *, + final: bool, + ) -> None: + key = (event.invocation_id, str(event.phase or "commentary")) + entry = bucket.setdefault(key, {"text": "", "final": False}) + if ( + len(bucket) == 1 + and entry["text"] == "" + and not any(k == (kind, key) for k in self._order) + ): + self._order.append((kind, key)) + entry["text"] += str(event.payload.get("text") or "") + if final: + entry["final"] = True + + # ---- 投影 ---- + + def transcript(self) -> dict[str, Any]: + """折叠为确定性 transcript(dict;``to_json`` 逐字节稳定)。""" + items: list[dict[str, Any]] = [] + for kind, key in self._order: + if kind == "text": + entry = self._text.get(key, {"text": "", "final": False}) + items.append( + { + "kind": "text", + "invocation_id": key[0], + "phase": key[1], + "text": entry["text"], + "final": entry["final"], + } + ) + elif kind == "reasoning": + entry = self._reasoning.get(key, {"text": "", "final": False}) + items.append( + { + "kind": "reasoning", + "invocation_id": key[0], + "phase": key[1], + "text": entry["text"], + "final": entry["final"], + } + ) + elif kind == "tool_call": + call = self._tool_calls.get(key, {}) + items.append( + { + "kind": "tool_call", + "call_id": key, + "name": call.get("name", ""), + "done": call.get("done", False), + "result": call.get("result"), + "invocation_id": call.get("invocation_id"), + } + ) + elif kind == "artifact": + art = self._artifacts.get(key, {}) + items.append( + { + "kind": "artifact", + "name": key, + "version": art.get("version", 1), + "text": art.get("text", ""), + "invocation_id": art.get("invocation_id"), + } + ) + return { + "items": items, + "run_status": {k: self._run_status[k] for k in sorted(self._run_status)}, + "extras": self._extras, + } + + def to_json(self) -> str: + """确定性 JSON 序列化(sort_keys + 紧凑分隔符),供 conformance 逐字节比对。""" + return json.dumps( + self.transcript(), ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + + +__all__ = ["RuntimeEventParser"] diff --git a/ksadk/events/replay.py b/ksadk/events/replay.py new file mode 100644 index 00000000..acd2df13 --- /dev/null +++ b/ksadk/events/replay.py @@ -0,0 +1,37 @@ +"""历史 replay — 基于 session 级 cursor 的跨 invocation 回放 (goal-12)。 + +replay 与 live 渲染**共用** :class:`~ksadk.events.parser.RuntimeEventParser`(单实现), +回放只是"从 store 按 cursor 读事件、喂同一个 parser"。因此 live 与 replay 不可能漂移 +(H2 高风险项),由 conformance fixture 逐字节断言兜底。 +""" + +from __future__ import annotations + +from typing import Optional + +from ksadk.events.parser import RuntimeEventParser +from ksadk.events.store import RuntimeEventStore + + +async def replay_transcript( + store: RuntimeEventStore, + session_id: str, + *, + after_seq_id: int = 0, + before_seq_id: Optional[int] = None, + parser: Optional[RuntimeEventParser] = None, +) -> RuntimeEventParser: + """跨 invocation 历史回放:按 session cursor 读事件,经共享 parser 折叠成 transcript。 + + 与 live 渲染同一条 parser 路径——live 是"事件边来边 feed",replay 是"从 store 读完 + 再 feed 同一个 parser",产物逐字节一致(conformance fixture 证明)。 + """ + parser = parser or RuntimeEventParser() + events = await store.list(session_id, after_seq_id=after_seq_id, before_seq_id=before_seq_id) + for event in events: + event.validate_conformance() + parser.feed(event) + return parser + + +__all__ = ["replay_transcript"] diff --git a/ksadk/events/runtime_event.py b/ksadk/events/runtime_event.py new file mode 100644 index 00000000..a1894798 --- /dev/null +++ b/ksadk/events/runtime_event.py @@ -0,0 +1,264 @@ +"""RuntimeEvent v1 schema (goal-02 / G0.2 冻结稿)。 + +事件只定义一次:Runtime 产生 → server 持久化 → gateway 透传 → UI/协议 adapter 消费。 +本模块只负责**定义层**(类型 + 序列化/反序列化 + 事件族清单);不改 runtime.py 发事件 +(那是后续阶段)。 + +设计约束(友商证伪,G0.2 冻结): + +- **additive + ``SCHEMA_VERSION``**:只增字段/事件类型,不改既有字段语义。 +- **相位字段** ``phase``:区分 ``commentary``(过程解说)vs ``final_answer``(最终答案), + 仅 text/reasoning 类事件使用。 +- **工具审批一等事件**(``approval.*``),不是普通 text;审批回包走独立命令/恢复通道, + 事件流上的 ``approval.resolved`` 仅作回放/审计(非 duplex stream)。 +""" + +from __future__ import annotations + +import time +import uuid +from enum import Enum +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +#: additive 演进锚点。冻结为 1;只增不改。 +SCHEMA_VERSION: Literal[1] = 1 + + +class EventPhase(str, Enum): + """相位:text/reasoning 类事件区分过程解说与最终答案。""" + + COMMENTARY = "commentary" + FINAL_ANSWER = "final_answer" + + +# --------------------------------------------------------------------------- +# 事件族(event_type 常量,v1 冻结)。新增事件类型只能 additive 追加。 +# --------------------------------------------------------------------------- + + +class EventType: + """v1 事件族清单(冻结)。按族分组;每族注释标明 payload 关键字段。""" + + # text(相位:commentary/final_answer)。payload: text, message_id + TEXT_DELTA = "text.delta" + TEXT_COMPLETED = "text.completed" + # reasoning(相位恒 commentary)。payload: text, summary + REASONING_DELTA = "reasoning.delta" + REASONING_COMPLETED = "reasoning.completed" + # tool。begin: call_id, name, args;end: call_id, name, result, error, duration_ms + TOOL_CALL_BEGIN = "tool.call.begin" + TOOL_CALL_END = "tool.call.end" + # artifact。payload: name, version, uri, mime + ARTIFACT_CREATED = "artifact.created" + ARTIFACT_UPDATED = "artifact.updated" + # approval(一等)。requested: approval_id, call_id, kind, detail; + # resolved: approval_id, call_id, decision(回放/审计) + APPROVAL_REQUESTED = "approval.requested" + APPROVAL_RESOLVED = "approval.resolved" + # run 生命周期。payload: status;progress?: progress;failed: error;canceled: cancel_result + RUN_STARTED = "run.started" + RUN_PROGRESS = "run.progress" + RUN_INTERRUPTED = "run.interrupted" + RUN_COMPLETED = "run.completed" + RUN_FAILED = "run.failed" + RUN_CANCELED = "run.canceled" + # checkpoint。payload: checkpoint_id, granularity(delta|snapshot), resume_target? + CHECKPOINT_CREATED = "checkpoint.created" + CHECKPOINT_RESUMED = "checkpoint.resumed" + # usage。payload: input_tokens, output_tokens, total_tokens, cached_tokens, reasoning_tokens + USAGE_REPORTED = "usage.reported" + # A2UI。payload: surface_id, block_id?, catalog?, data? + A2UI_SURFACE_BEGIN = "a2ui.surface.begin" + A2UI_SURFACE_UPDATE = "a2ui.surface.update" + A2UI_SURFACE_END = "a2ui.surface.end" + A2UI_INTERACTION = "a2ui.interaction" + A2UI_ACTION = "a2ui.action" + # remote A2A。payload: task_id, origin(remote agent url/space), status?, artifact? + A2A_TASK_CREATED = "a2a.task.created" + A2A_TASK_STATUS = "a2a.task.status" + A2A_TASK_ARTIFACT = "a2a.task.artifact" + + +#: 全部 v1 事件类型(供校验/枚举)。 +ALL_EVENT_TYPES: frozenset[str] = frozenset( + { + EventType.TEXT_DELTA, + EventType.TEXT_COMPLETED, + EventType.REASONING_DELTA, + EventType.REASONING_COMPLETED, + EventType.TOOL_CALL_BEGIN, + EventType.TOOL_CALL_END, + EventType.ARTIFACT_CREATED, + EventType.ARTIFACT_UPDATED, + EventType.APPROVAL_REQUESTED, + EventType.APPROVAL_RESOLVED, + EventType.RUN_STARTED, + EventType.RUN_PROGRESS, + EventType.RUN_INTERRUPTED, + EventType.RUN_COMPLETED, + EventType.RUN_FAILED, + EventType.RUN_CANCELED, + EventType.CHECKPOINT_CREATED, + EventType.CHECKPOINT_RESUMED, + EventType.USAGE_REPORTED, + EventType.A2UI_SURFACE_BEGIN, + EventType.A2UI_SURFACE_UPDATE, + EventType.A2UI_SURFACE_END, + EventType.A2UI_INTERACTION, + EventType.A2UI_ACTION, + EventType.A2A_TASK_CREATED, + EventType.A2A_TASK_STATUS, + EventType.A2A_TASK_ARTIFACT, + } +) + +#: 各 event_type 的 payload 必填键(conformance 用;additive —— 只允许增键)。 +#: 信封字段是硬冻结;payload 必填键是 v1 最低契约,后续版本只能加可选键。 +EVENT_PAYLOAD_REQUIRED_KEYS: dict[str, frozenset[str]] = { + EventType.TEXT_DELTA: frozenset({"text"}), + EventType.TEXT_COMPLETED: frozenset({"text"}), + EventType.REASONING_DELTA: frozenset({"text"}), + EventType.REASONING_COMPLETED: frozenset({"text"}), + EventType.TOOL_CALL_BEGIN: frozenset({"call_id", "name"}), + EventType.TOOL_CALL_END: frozenset({"call_id", "name"}), + EventType.ARTIFACT_CREATED: frozenset({"name", "version"}), + EventType.ARTIFACT_UPDATED: frozenset({"name", "version"}), + EventType.APPROVAL_REQUESTED: frozenset({"approval_id", "call_id", "kind"}), + EventType.APPROVAL_RESOLVED: frozenset({"approval_id", "call_id", "decision"}), + EventType.RUN_STARTED: frozenset({"status"}), + EventType.RUN_PROGRESS: frozenset({"status"}), + EventType.RUN_INTERRUPTED: frozenset({"status"}), + EventType.RUN_COMPLETED: frozenset({"status"}), + EventType.RUN_FAILED: frozenset({"status", "error"}), + EventType.RUN_CANCELED: frozenset({"status"}), + EventType.CHECKPOINT_CREATED: frozenset({"checkpoint_id", "granularity"}), + EventType.CHECKPOINT_RESUMED: frozenset({"checkpoint_id"}), + EventType.USAGE_REPORTED: frozenset({"input_tokens", "output_tokens", "total_tokens"}), + EventType.A2UI_SURFACE_BEGIN: frozenset({"surface_id"}), + EventType.A2UI_SURFACE_UPDATE: frozenset({"surface_id"}), + EventType.A2UI_SURFACE_END: frozenset({"surface_id"}), + EventType.A2UI_INTERACTION: frozenset({"surface_id"}), + EventType.A2UI_ACTION: frozenset({"surface_id"}), + EventType.A2A_TASK_CREATED: frozenset({"task_id", "origin"}), + EventType.A2A_TASK_STATUS: frozenset({"task_id", "origin", "status"}), + EventType.A2A_TASK_ARTIFACT: frozenset({"task_id", "origin"}), +} + +#: 仅 text/reasoning 类事件使用相位字段。 +_PHASE_AWARE_TYPES: frozenset[str] = frozenset( + { + EventType.TEXT_DELTA, + EventType.TEXT_COMPLETED, + EventType.REASONING_DELTA, + EventType.REASONING_COMPLETED, + } +) + + +class RuntimeEvent(BaseModel): + """RuntimeEvent v1 信封。 + + 字段全部硬冻结(additive 演进只允许新增可选字段)。``payload`` 按 event_type + 承载,最低必填键见 :data:`EVENT_PAYLOAD_REQUIRED_KEYS`。 + """ + + schema_version: Literal[1] = SCHEMA_VERSION + event_id: str + event_type: str + timestamp: float + agent_id: str + user_id: str + session_id: str + invocation_id: str + seq_id: int + phase: Optional[Literal["commentary", "final_answer"]] = None + payload: dict[str, Any] = Field(default_factory=dict) + + # ---- 构造 ---- + + @classmethod + def create( + cls, + event_type: str, + *, + agent_id: str, + user_id: str, + session_id: str, + invocation_id: str, + seq_id: int, + payload: Optional[dict[str, Any]] = None, + phase: Optional[str] = None, + event_id: Optional[str] = None, + timestamp: Optional[float] = None, + ) -> "RuntimeEvent": + """便捷构造:自动补 event_id / timestamp,并按 event_type 校验相位与 payload。""" + event = cls( + event_id=event_id or f"evt_{uuid.uuid4().hex}", + event_type=event_type, + timestamp=time.time() if timestamp is None else timestamp, + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + invocation_id=invocation_id, + seq_id=seq_id, + phase=phase, # type: ignore[arg-type] + payload=payload or {}, + ) + event.validate_conformance() + return event + + # ---- 序列化 ---- + + def to_dict(self) -> dict[str, Any]: + """序列化为 dict(含全部信封字段 + payload)。""" + return self.model_dump(mode="json", exclude_none=True) + + def to_json(self) -> str: + """序列化为 JSON 字符串。""" + return self.model_dump_json(exclude_none=True) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "RuntimeEvent": + """从 dict 反序列化。与 :meth:`create` 一致过 conformance: + 未知 event_type / 相位滥用 / 缺必填键抛 ``ValueError``,不得混入系统。""" + event = cls.model_validate(data) + event.validate_conformance() + return event + + @classmethod + def from_json(cls, raw: str) -> "RuntimeEvent": + """从 JSON 字符串反序列化(同 :meth:`from_dict` 过 conformance)。""" + event = cls.model_validate_json(raw) + event.validate_conformance() + return event + + # ---- conformance ---- + + def validate_conformance(self) -> None: + """按 v1 契约校验:事件类型已知、相位仅用于 text/reasoning、payload 必填键齐全。 + + additive 演进:允许 payload 含额外键(不作 strict 拒绝),只校验最低必填键。 + 未知 event_type / 缺必填键 / 相位滥用抛 :class:`ValueError`。 + """ + if self.event_type not in ALL_EVENT_TYPES: + raise ValueError(f"unknown event_type: {self.event_type!r}(v1 事件族之外)") + if self.phase is not None and self.event_type not in _PHASE_AWARE_TYPES: + raise ValueError( + f"phase 仅用于 text/reasoning 事件,{self.event_type!r} 不应带 phase={self.phase!r}" + ) + required = EVENT_PAYLOAD_REQUIRED_KEYS.get(self.event_type, frozenset()) + missing = required - set(self.payload.keys()) + if missing: + raise ValueError(f"event_type {self.event_type!r} payload 缺必填键: {sorted(missing)}") + + +__all__ = [ + "ALL_EVENT_TYPES", + "EVENT_PAYLOAD_REQUIRED_KEYS", + "EventPhase", + "EventType", + "RuntimeEvent", + "SCHEMA_VERSION", +] diff --git a/ksadk/events/store.py b/ksadk/events/store.py new file mode 100644 index 00000000..14bc7d3e --- /dev/null +++ b/ksadk/events/store.py @@ -0,0 +1,317 @@ +"""RuntimeEventStore — 统一事件 store + 两类订阅 + projection (goal-10,H2 §4.3)。 + +复用现有 ``SessionEvent(seq_id cursor)`` 持久化骨架(session service),**不另造存储、 +不改表**:RuntimeEvent 的 ``phase``/``payload``/``schema_version``/``user_id`` 打包进 +``SessionEvent.content``/``metadata``(均为自由 dict),并以 ``_RUNTIME_MARKER`` 标记区分 +legacy session 事件(assistant_message/run_status 等),读取时只还原 runtime 事件。 + +- ``append`` / ``list``:RuntimeEvent ↔ SessionEvent 双向映射。 +- ``subscribe_run``:单 invocation,终态(completed/failed/canceled)后关闭(对齐现有 + run 级 SSE 语义,新 schema)。 +- ``subscribe_session``:session 级 cursor stream,跨 invocation,支持 run 后 action 与 + replay(A2UI 依赖)。 +- ``project``:replay / 增量 projection(fold)。 +- cursor 断线续传:订阅方持 ``last_seq_id``,断线后按 ``after_seq_id`` 重连,不丢不重。 +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, AsyncIterator, Callable, Iterable, Optional + +from ksadk.events.runtime_event import EventType, RuntimeEvent +from ksadk.sessions.base import SessionEvent + +logger = logging.getLogger(__name__) + +#: SessionEvent.metadata 中的标记:该条是由 RuntimeEvent 持久化来的(区分 legacy 事件)。 +_RUNTIME_MARKER = "ksadk_runtime_event" +_A2A_TASK_AGENT_STATE_KEY = "__ksadk_a2a_task_agents" + +#: run 终态(subscribe_run 遇到即关闭;interrupted 是 input-required 暂停,非终态)。 +_RUN_TERMINAL_EVENT_TYPES = frozenset( + { + EventType.RUN_COMPLETED, + EventType.RUN_FAILED, + EventType.RUN_CANCELED, + } +) + +#: 默认订阅轮询间隔(秒)与单条流上限(秒,防泄漏)。 +_DEFAULT_POLL_INTERVAL = 0.25 +_DEFAULT_STREAM_TIMEOUT = 5 * 60 + + +# --------------------------------------------------------------------------- +# RuntimeEvent ↔ SessionEvent 映射 +# --------------------------------------------------------------------------- + + +def runtime_event_to_session_event(event: RuntimeEvent) -> SessionEvent: + """把 RuntimeEvent 打包为 SessionEvent(content/metadata 承载新 schema 字段,不改表)。""" + event.validate_conformance() + return SessionEvent( + id=event.event_id, + session_id=event.session_id, + author=event.agent_id, + event_type=event.event_type, + content={"phase": event.phase, "payload": dict(event.payload)}, + timestamp=event.timestamp, + seq_id=event.seq_id, + invocation_id=event.invocation_id, + metadata={ + _RUNTIME_MARKER: True, + "user_id": event.user_id, + "schema_version": event.schema_version, + }, + ) + + +def session_event_to_runtime_event(event: SessionEvent) -> Optional[RuntimeEvent]: + """把 SessionEvent 还原为 RuntimeEvent;非 runtime 事件(无标记)返回 None。""" + if not (event.metadata or {}).get(_RUNTIME_MARKER): + return None + content = event.content or {} + return RuntimeEvent.create( + event.event_type, + agent_id=event.author, + user_id=str(event.metadata.get("user_id") or ""), + session_id=event.session_id, + invocation_id=event.invocation_id or "", + seq_id=event.seq_id, + payload=dict(content.get("payload") or {}), + phase=content.get("phase"), + event_id=event.id, + timestamp=event.timestamp, + ) + + +# --------------------------------------------------------------------------- +# RuntimeEventStore +# --------------------------------------------------------------------------- + + +class RuntimeEventStore: + """统一事件 store(复用 session service 的 seq_id cursor 持久化骨架)。""" + + def __init__(self, session_service: Any) -> None: + self._service = session_service + + # ---- append ---- + + async def append(self, events: Iterable[RuntimeEvent]) -> list[RuntimeEvent]: + """持久化一组 RuntimeEvent,返回存储层分配 cursor 后的事件。""" + appended: list[RuntimeEvent] = [] + for event in events: + appended.append(await self.append_one(event)) + return appended + + async def append_one(self, event: RuntimeEvent) -> RuntimeEvent: + """Idempotently persist one event using its durable ``event_id``. + + Replayed wire events return their original store-assigned cursor. Reuse + of an ID for different content is rejected rather than silently losing a + legal event. + """ + persisted, _created = await self.reserve_once(event) + return persisted + + async def reserve_once(self, event: RuntimeEvent) -> tuple[RuntimeEvent, bool]: + """Durably claim ``event.event_id`` and report whether this caller won. + + SQL backends enforce a unique event id, so this is also the command + reservation seam for side effects such as checkpoint resume. A loser + receives the existing identical fact with ``created=False`` and must + not repeat the side effect. + """ + existing = await self._event_by_id(event.session_id, event.event_id) + if existing is not None: + self._assert_same_event(existing, event) + return existing, False + try: + stored = await self._service.append_event( + event.session_id, runtime_event_to_session_event(event) + ) + except Exception: + # Durable backends enforce a unique event id. A concurrent writer + # may win between the read and append; resolve that race by reading + # the persisted fact and validating its content. + existing = await self._event_by_id(event.session_id, event.event_id) + if existing is None: + raise + self._assert_same_event(existing, event) + return existing, False + persisted = session_event_to_runtime_event(stored) + if persisted is None: # pragma: no cover - marker is set above by construction + raise RuntimeError("RuntimeEvent 持久化后缺少 runtime marker") + return persisted, True + + async def _event_by_id(self, session_id: str, event_id: str) -> RuntimeEvent | None: + raw = await self._service.get_events(session_id) + for stored in raw: + if stored.id != event_id: + continue + return session_event_to_runtime_event(stored) + return None + + @staticmethod + def _assert_same_event(existing: RuntimeEvent, candidate: RuntimeEvent) -> None: + comparable = ( + "event_type", + "agent_id", + "user_id", + "session_id", + "invocation_id", + "phase", + "payload", + ) + if any(getattr(existing, field) != getattr(candidate, field) for field in comparable): + raise ValueError(f"RuntimeEvent id collision for {candidate.event_id!r}") + + async def set_task_agent(self, session_id: str, task_id: str, agent_id: str) -> None: + """Persist the outbound A2A task locator in session state.""" + session = await self._service.get_session_metadata(session_id) + if session is None: + raise ValueError(f"A2A space session {session_id!r} not found") + current = await self._service.get_state( + session.agent_id, + session.user_id, + session.id, + scope="session", + ) + mapping = dict((current.state if current else {}).get(_A2A_TASK_AGENT_STATE_KEY) or {}) + existing = mapping.get(task_id) + if existing and existing != agent_id: + raise ValueError(f"A2A task {task_id!r} is already bound to another agent") + mapping[task_id] = agent_id + await self._service.update_state( + agent_id=session.agent_id, + user_id=session.user_id, + session_id=session.id, + scope="session", + state_delta={_A2A_TASK_AGENT_STATE_KEY: mapping}, + ) + + async def get_task_agent(self, session_id: str, task_id: str) -> str | None: + """Resolve a persisted outbound A2A task locator.""" + session = await self._service.get_session_metadata(session_id) + if session is None: + return None + current = await self._service.get_state( + session.agent_id, + session.user_id, + session.id, + scope="session", + ) + mapping = dict((current.state if current else {}).get(_A2A_TASK_AGENT_STATE_KEY) or {}) + value = mapping.get(task_id) + return str(value) if value else None + + # ---- list ---- + + async def list( + self, + session_id: str, + *, + after_seq_id: int = 0, + before_seq_id: Optional[int] = None, + invocation_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> list[RuntimeEvent]: + """按 seq cursor 读 RuntimeEvent(升序;可按 invocation 过滤 / before 上界回放)。""" + raw = await self._service.get_events( + session_id, + after_seq_id=after_seq_id, + before_seq_id=before_seq_id, + limit=limit, + ) + events = [e for e in (session_event_to_runtime_event(se) for se in raw) if e is not None] + if invocation_id is not None: + events = [e for e in events if e.invocation_id == invocation_id] + events.sort(key=lambda e: e.seq_id) + return events + + # ---- 两类订阅 ---- + + async def subscribe_run( + self, + session_id: str, + invocation_id: str, + *, + after_seq_id: int = 0, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_STREAM_TIMEOUT, + ) -> AsyncIterator[RuntimeEvent]: + """单 invocation 订阅:只产该 invocation 的 RuntimeEvent,终态后关闭。 + + 断线续传:调用方持返回事件的 ``seq_id``,断线后以 ``after_seq_id`` 重连即可续传, + 不丢(>after 的全部重发)、不重(<=after 的不重发)。 + """ + last = int(after_seq_id or 0) + deadline = asyncio.get_event_loop().time() + timeout + while True: + events = await self.list(session_id, after_seq_id=last, invocation_id=invocation_id) + for event in events: + last = max(last, event.seq_id) + yield event + if event.event_type in _RUN_TERMINAL_EVENT_TYPES: + return + if asyncio.get_event_loop().time() > deadline: + return + await asyncio.sleep(poll_interval) + + async def subscribe_session( + self, + session_id: str, + *, + after_seq_id: int = 0, + poll_interval: float = _DEFAULT_POLL_INTERVAL, + timeout: float = _DEFAULT_STREAM_TIMEOUT, + ) -> AsyncIterator[RuntimeEvent]: + """session 级 cursor stream:跨 invocation 产全部 RuntimeEvent(replay + live)。 + + 支持 run 后 action 与跨 invocation replay(A2UI 依赖);断线续传同 subscribe_run。 + """ + last = int(after_seq_id or 0) + deadline = asyncio.get_event_loop().time() + timeout + while True: + events = await self.list(session_id, after_seq_id=last) + for event in events: + last = max(last, event.seq_id) + yield event + if asyncio.get_event_loop().time() > deadline: + return + await asyncio.sleep(poll_interval) + + # ---- projection / replay ---- + + async def project( + self, + session_id: str, + projection: Optional[Callable[[Any, RuntimeEvent], Any]] = None, + *, + initial: Any = None, + after_seq_id: int = 0, + before_seq_id: Optional[int] = None, + ) -> Any: + """replay / projection。 + + 默认(``projection=None``):返回按 seq 升序的 RuntimeEvent 序列(replay)。 + 给定 ``projection(acc, event) -> acc``:自 ``initial`` 起 fold 全部事件,支持 + 增量 projection(以 ``after_seq_id`` 从某个 checkpoint 续投影)。 + """ + events = await self.list(session_id, after_seq_id=after_seq_id, before_seq_id=before_seq_id) + if projection is None: + return events + acc = initial + for event in events: + acc = projection(acc, event) + return acc + + +__all__ = [ + "RuntimeEventStore", + "runtime_event_to_session_event", + "session_event_to_runtime_event", +] diff --git a/ksadk/harness/__init__.py b/ksadk/harness/__init__.py new file mode 100644 index 00000000..81c40659 --- /dev/null +++ b/ksadk/harness/__init__.py @@ -0,0 +1,34 @@ +"""HarnessApp — 统一 Runtime 的可部署交付物 / composition root (goal-08)。""" + +from ksadk.harness.app import HarnessApp, HarnessCapabilities, HarnessPlugin +from ksadk.harness.config import ( + HarnessConfig, + HarnessConfigError, + McpToolSpec, + SandboxPolicy, +) +from ksadk.harness.reasoner import ( + HarnessReasoner, + HarnessReasoningTurn, + HarnessToolCall, + LiteLLMHarnessReasoner, +) +from ksadk.harness.runner import YamlAgentRunner +from ksadk.harness.sandbox import HarnessSandboxExecutor, SandboxPolicyDenied + +__all__ = [ + "HarnessApp", + "HarnessCapabilities", + "HarnessConfig", + "HarnessConfigError", + "HarnessPlugin", + "HarnessReasoner", + "HarnessReasoningTurn", + "HarnessSandboxExecutor", + "HarnessToolCall", + "LiteLLMHarnessReasoner", + "McpToolSpec", + "SandboxPolicy", + "SandboxPolicyDenied", + "YamlAgentRunner", +] diff --git a/ksadk/harness/app.py b/ksadk/harness/app.py new file mode 100644 index 00000000..81e044f1 --- /dev/null +++ b/ksadk/harness/app.py @@ -0,0 +1,278 @@ +"""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 的职责, +不在本层做。 +""" + +from __future__ import annotations + +import logging +import tempfile +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any, Callable, Optional, Protocol, Sequence + +from fastapi import FastAPI + +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.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 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 + a2a: bool = False + agui: bool = False + + +class HarnessApp: + """统一 Runtime 的 composition root(skeleton)。""" + + def __init__( + self, + config: HarnessConfig, + *, + plugins: Sequence[HarnessPlugin] = (), + adapter_builder: Optional[Callable[[BaseRunner], RuntimeAdapter]] = None, + reasoner: HarnessReasoner | None = None, + workspace_root: str | Path | None = None, + a2a: Any | None = None, + ) -> None: + self._config = config + self._plugins = list(plugins) + self._adapter_builder = adapter_builder + self._reasoner = reasoner + self._a2a = a2a + self._owned_workspace: tempfile.TemporaryDirectory[str] | None + if workspace_root is None: + self._owned_workspace = tempfile.TemporaryDirectory(prefix="ksadk-harness-") + resolved_workspace: str | Path = self._owned_workspace.name + else: + 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. + from ksadk.sessions import create_session_service + + self._session_service = create_session_service( + 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 ---- + + def apply_overrides(self, overrides: Optional[dict[str, Any]]) -> HarnessConfig: + """per-invocation override:只允许最小子集内字段,超出报错(不静默忽略)。""" + if not overrides: + return self._config + unknown = set(overrides) - set(_OVERRIDEABLE) + if unknown: + raise ValueError( + f"override 仅允许 {list(_OVERRIDEABLE)}(最小子集),不支持: {sorted(unknown)}" + ) + 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 + + def _route_groups(self) -> set[str]: + groups = {"health_meta", "models", "feedback", "tools", "ui_bootstrap"} + if self._capabilities.responses: + groups.update({"run", "openai_compat"}) + if self._capabilities.sessions: + groups.update({"sessions", "sessions_adk_compat"}) + if self._capabilities.files: + groups.add("workspace") + if self._capabilities.agui: + groups.add("agui") + return groups + + def _request_with_overrides( + self, request: StartRequest, overrides: Optional[dict[str, Any]] + ) -> StartRequest: + 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)) + + def stream(self, handle: Any): + return self.adapter().stream(handle) + + async def cancel(self, handle: Any): + return await self.adapter().cancel(handle) + + async def resume(self, handle: Any, target: Any, payload: Any = None): + return await self.adapter().resume(handle, target, payload) + + async def checkpoint(self, handle: Any): + return await self.adapter().checkpoint(handle) + + async def close(self, handle: Any): + return await self.adapter().close(handle) + + def _configure_routes(self, app: FastAPI, state: Any, groups: set[str]) -> None: + """Attach only route groups; never install integrated workspace/terminal routes.""" + del state + load_route_modules() + ordered = sorted(group for group in groups if group != "health_meta") + if "health_meta" in groups: + ordered.append("health_meta") + for group in ordered: + router = GROUP_ROUTERS.get(group) + if router is not 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() + app = create_runtime_app( + RuntimeAppConfig( + runner=runner, + runtime_type="codex" if self._config.runtime == "codex" else "harness", + route_groups=self._route_groups(), + a2a=self._a2a, + runtime_adapter=self.adapter() if self._a2a is not None else None, + session_backend_provider=lambda: { + "Backend": "memory", + "Shared": False, + "ProductionSafe": False, + "ContinuityDefault": "local_only", + }, + ), + 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 + return app + + +__all__ = ["HarnessApp", "HarnessCapabilities", "HarnessPlugin"] diff --git a/ksadk/harness/config.py b/ksadk/harness/config.py new file mode 100644 index 00000000..5563a55d --- /dev/null +++ b/ksadk/harness/config.py @@ -0,0 +1,181 @@ +"""HarnessApp yaml 配置 — 最小子集 + 严格校验 (goal-08)。 + +本期硬边界(防膨胀):**只支持最小子集** ``model`` / ``prompt``(instruction)/ +``mcp_tools`` / ``sandbox``。超出子集的字段(memory/knowledge/workflow/tracing 等) +**明确报"暂不支持"**,不硬翻、不静默忽略。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import yaml # type: ignore[import-untyped] + +#: 顶层允许的最小子集字段。 +_ALLOWED_TOP_LEVEL = frozenset({"model", "prompt", "mcp_tools", "sandbox", "runtime"}) +#: runtime 允许的值。 +_ALLOWED_RUNTIME = frozenset({"yaml", "codex"}) +#: mcp_tools 单项允许字段。 +_ALLOWED_MCP_TOOL = frozenset({"name", "url", "api_key", "tool_filter", "tool_name_prefix"}) +#: sandbox 允许字段。 +_ALLOWED_SANDBOX = frozenset({"read_only"}) + +#: 已知但本期不支持的字段,给出更明确的指引。 +_KNOWN_UNSUPPORTED = { + "memory": "memory(记忆)暂不支持,后续阶段提供", + "knowledge": "knowledge(知识库)暂不支持,后续阶段提供", + "workflow": "workflow(工作流编排)暂不支持,后续阶段提供", + "tracing": "tracing(可观测)暂不支持,后续阶段提供", + "skills": "skills(技能)暂不支持,后续阶段提供", +} + + +class HarnessConfigError(ValueError): + """HarnessApp yaml 配置错误(含"暂不支持"指引)。""" + + +@dataclass(frozen=True) +class McpToolSpec: + """MCP 工具条目(最小子集)。""" + + name: str + url: str + api_key: Optional[str] = None + tool_filter: tuple[str, ...] = () + tool_name_prefix: Optional[str] = None + + +@dataclass(frozen=True) +class SandboxPolicy: + """sandbox 策略。默认 read-only(goal-08:sandbox 默认 read-only)。""" + + read_only: bool = True + + +@dataclass(frozen=True) +class HarnessConfig: + """HarnessApp 的最小 yaml 配置。""" + + model: str + prompt: str + mcp_tools: tuple[McpToolSpec, ...] = () + sandbox: SandboxPolicy = field(default_factory=SandboxPolicy) + runtime: str = "yaml" + """runtime 后端:``yaml``(YamlAgentRunner+LiteLLM)| ``codex``(CodexRunner+codex CLI)。""" + + @classmethod + def from_dict(cls, data: dict[str, Any], *, source: str = "yaml") -> "HarnessConfig": + if not isinstance(data, dict): + raise HarnessConfigError(f"{source}: 顶层必须是 mapping,得到 {type(data).__name__}") + + _reject_unknown_keys(data, _ALLOWED_TOP_LEVEL, where=source) + + model = _require_str(data, "model", source) + prompt = _require_str(data, "prompt", source) + + runtime = str(data.get("runtime", "yaml")).strip().lower() + if runtime not in _ALLOWED_RUNTIME: + raise HarnessConfigError( + f"{source}: runtime 仅支持 {sorted(_ALLOWED_RUNTIME)},得到 {runtime!r}" + ) + + mcp_tools = tuple( + _parse_mcp_tool(item, index, source) + for index, item in enumerate( + _require_list(data.get("mcp_tools", []), "mcp_tools", source) + ) + ) + sandbox = _parse_sandbox(data.get("sandbox", {}), source) + return cls( + model=model, prompt=prompt, mcp_tools=mcp_tools, sandbox=sandbox, runtime=runtime + ) + + @classmethod + def from_yaml(cls, path: str | Path) -> "HarnessConfig": + path = Path(path) + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise HarnessConfigError(f"{path}: YAML 解析失败: {exc}") from exc + if data is None: + data = {} + return cls.from_dict(data, source=str(path)) + + +def _reject_unknown_keys(data: dict[str, Any], allowed: frozenset[str], *, where: str) -> None: + for key in data: + if key in allowed: + continue + if key in _KNOWN_UNSUPPORTED: + raise HarnessConfigError(f"{where}: 字段 {key!r} —— {_KNOWN_UNSUPPORTED[key]}") + raise HarnessConfigError( + f"{where}: 未知字段 {key!r};本期最小子集仅支持 {sorted(allowed)}," + "超出子集的字段不予支持(不静默忽略)" + ) + + +def _require_str(data: dict[str, Any], key: str, where: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise HarnessConfigError(f"{where}: 字段 {key!r} 必填且必须是非空字符串") + return value.strip() + + +def _require_list(value: Any, key: str, where: str) -> list: + if value is None: + return [] + if not isinstance(value, list): + raise HarnessConfigError(f"{where}: 字段 {key!r} 必须是 list") + return value + + +def _parse_mcp_tool(item: Any, index: int, where: str) -> McpToolSpec: + if not isinstance(item, dict): + raise HarnessConfigError(f"{where}: mcp_tools[{index}] 必须是 mapping") + _reject_unknown_keys(item, _ALLOWED_MCP_TOOL, where=f"{where}.mcp_tools[{index}]") + name = _require_str(item, "name", f"{where}.mcp_tools[{index}]") + url = _require_str(item, "url", f"{where}.mcp_tools[{index}]") + tool_filter = item.get("tool_filter", ()) + if not isinstance(tool_filter, (list, tuple)) or not all( + isinstance(t, str) for t in tool_filter + ): + raise HarnessConfigError(f"{where}.mcp_tools[{index}].tool_filter 必须是字符串 list") + api_key = item.get("api_key") + if api_key is not None and not isinstance(api_key, str): + raise HarnessConfigError(f"{where}.mcp_tools[{index}].api_key 必须是字符串") + prefix = item.get("tool_name_prefix") + if prefix is not None and not isinstance(prefix, str): + raise HarnessConfigError(f"{where}.mcp_tools[{index}].tool_name_prefix 必须是字符串") + return McpToolSpec( + name=name, + url=url, + api_key=api_key, + tool_filter=tuple(tool_filter), + tool_name_prefix=prefix, + ) + + +def _parse_sandbox(value: Any, where: str) -> SandboxPolicy: + if value is None: + return SandboxPolicy() + if not isinstance(value, dict): + raise HarnessConfigError(f"{where}: sandbox 必须是 mapping") + _reject_unknown_keys(value, _ALLOWED_SANDBOX, where=f"{where}.sandbox") + read_only = value.get("read_only", True) + if not isinstance(read_only, bool): + raise HarnessConfigError(f"{where}.sandbox.read_only 必须是 bool") + if not read_only: + raise HarnessConfigError( + f"{where}.sandbox.read_only=false 暂不支持;Harness 当前只提供执行层强制的只读策略" + ) + return SandboxPolicy(read_only=read_only) + + +__all__ = [ + "HarnessConfig", + "HarnessConfigError", + "McpToolSpec", + "SandboxPolicy", +] diff --git a/ksadk/harness/reasoner.py b/ksadk/harness/reasoner.py new file mode 100644 index 00000000..59725d7d --- /dev/null +++ b/ksadk/harness/reasoner.py @@ -0,0 +1,105 @@ +"""Reasoning boundary used by the Harness runner.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from typing import Any, Protocol, Sequence + + +@dataclass(frozen=True) +class HarnessToolCall: + call_id: str + name: str + arguments: dict[str, Any] + + +@dataclass(frozen=True) +class HarnessReasoningTurn: + final_text: str | None = None + tool_calls: tuple[HarnessToolCall, ...] = () + + +class HarnessReasoner(Protocol): + async def complete( + self, + *, + model: str, + prompt: str, + messages: Sequence[dict[str, Any]], + tools: Sequence[Any], + ) -> HarnessReasoningTurn: ... + + +class LiteLLMHarnessReasoner: + """Use the project's OpenAI-compatible LiteLLM configuration for tool reasoning.""" + + async def complete( + self, + *, + model: str, + prompt: str, + messages: Sequence[dict[str, Any]], + tools: Sequence[Any], + ) -> HarnessReasoningTurn: + del prompt + try: + from litellm import acompletion + except ImportError as exc: # pragma: no cover - depends on optional install + raise RuntimeError( + "Harness reasoning requires the 'adk' extra (litellm); " + "install ksadk[adk] or inject a HarnessReasoner" + ) from exc + + resolved_model = model if "/" in model else f"openai/{model}" + kwargs: dict[str, Any] = { + "model": resolved_model, + "messages": list(messages), + "tools": [tool.openai_schema for tool in tools], + "tool_choice": "auto", + } + base_url = os.getenv("OPENAI_BASE_URL") + api_key = os.getenv("OPENAI_API_KEY") + if base_url: + kwargs["base_url"] = base_url + if api_key: + kwargs["api_key"] = api_key + response = await acompletion(**kwargs) + choices = getattr(response, "choices", None) or [] + if not choices: + raise RuntimeError(f"Harness model {model!r} returned no choices") + message = choices[0].message + calls: list[HarnessToolCall] = [] + for index, call in enumerate(getattr(message, "tool_calls", None) or []): + function = getattr(call, "function", None) + name = str(getattr(function, "name", "") or "").strip() + raw_arguments = getattr(function, "arguments", None) or "{}" + try: + arguments = ( + json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments + ) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Harness model emitted invalid JSON arguments for tool {name!r}" + ) from exc + if not name or not isinstance(arguments, dict): + raise RuntimeError("Harness model emitted an invalid tool call") + calls.append( + HarnessToolCall( + call_id=str(getattr(call, "id", "") or f"tool-call-{index}"), + name=name, + arguments=dict(arguments), + ) + ) + content = getattr(message, "content", None) + final_text = str(content) if content is not None else None + return HarnessReasoningTurn(final_text=final_text, tool_calls=tuple(calls)) + + +__all__ = [ + "HarnessReasoner", + "HarnessReasoningTurn", + "HarnessToolCall", + "LiteLLMHarnessReasoner", +] diff --git a/ksadk/harness/runner.py b/ksadk/harness/runner.py new file mode 100644 index 00000000..f83b0f5a --- /dev/null +++ b/ksadk/harness/runner.py @@ -0,0 +1,211 @@ +"""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/sandbox.py b/ksadk/harness/sandbox.py new file mode 100644 index 00000000..244226ef --- /dev/null +++ b/ksadk/harness/sandbox.py @@ -0,0 +1,141 @@ +"""Invocation-owned execution policy for Harness sandbox tools.""" + +from __future__ import annotations + +import asyncio +import os +import shlex +import subprocess +from pathlib import Path +from typing import Any + + +class SandboxPolicyDenied(PermissionError): + """Raised before execution when a Harness sandbox action violates policy.""" + + +_READ_COMMANDS = frozenset({"cat", "head", "ls", "pwd", "stat", "tail", "wc"}) +_NO_PATH_COMMANDS = frozenset({"pwd"}) +_REQUIRE_PATH_COMMANDS = frozenset({"cat", "head", "stat", "tail", "wc"}) +_VALUE_OPTIONS = frozenset({"-n", "--bytes", "--lines", "--max-unchanged-stats"}) +_SHELL_OPERATORS = frozenset({"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}) + + +class HarnessSandboxExecutor: + """Run a deliberately small read-only command surface inside one workspace. + + Commands are executed without a shell. Path operands are resolved before the + child process starts, including symlinks, so reads cannot escape the configured + workspace. The narrow command surface also excludes network clients and process + launchers entirely. + """ + + def __init__(self, *, workspace_root: str | Path, read_only: bool = True) -> None: + self._workspace_root = Path(workspace_root).expanduser().resolve() + self._workspace_root.mkdir(parents=True, exist_ok=True) + self._read_only = read_only + + @property + def workspace_root(self) -> Path: + return self._workspace_root + + @property + def read_only(self) -> bool: + return self._read_only + + async def read_file(self, path: str) -> dict[str, Any]: + target = self._resolve_path(path) + content = await asyncio.to_thread(target.read_text, encoding="utf-8") + return { + "ok": True, + "path": target.relative_to(self._workspace_root).as_posix(), + "content": content, + "policy": "read-only", + } + + async def run_command(self, command: str) -> dict[str, Any]: + argv = self._validate_command(command) + completed = await asyncio.to_thread( + subprocess.run, + argv, + cwd=self._workspace_root, + env={"PATH": os.environ.get("PATH", "")}, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + return { + "ok": completed.returncode == 0, + "command": " ".join(argv), + "stdout": completed.stdout, + "stderr": completed.stderr, + "exit_code": completed.returncode, + "policy": "read-only", + } + + def _validate_command(self, command: str) -> list[str]: + try: + argv = shlex.split(str(command or ""), posix=True) + except ValueError as exc: + raise SandboxPolicyDenied(f"read-only sandbox denied malformed command: {exc}") from exc + if not argv: + raise SandboxPolicyDenied("read-only sandbox denied empty command") + if any(argument in _SHELL_OPERATORS for argument in argv): + raise SandboxPolicyDenied( + "read-only sandbox denied shell redirection, pipelines, or command chaining" + ) + executable = argv[0] + if executable not in _READ_COMMANDS: + raise SandboxPolicyDenied( + f"read-only sandbox denied command {executable!r}; network, write, and " + "dangerous process actions are not allowed" + ) + if executable in _NO_PATH_COMMANDS: + if len(argv) != 1: + raise SandboxPolicyDenied( + f"read-only sandbox denied arguments for command {executable!r}" + ) + return argv + + normalized = [executable] + skip_option_value = False + saw_path = False + for argument in argv[1:]: + if skip_option_value: + normalized.append(argument) + skip_option_value = False + continue + if argument == "--": + normalized.append(argument) + continue + if argument in _VALUE_OPTIONS: + normalized.append(argument) + skip_option_value = True + continue + if argument.startswith("-"): + normalized.append(argument) + continue + target = self._resolve_path(argument) + normalized.append(str(target)) + saw_path = True + if executable in _REQUIRE_PATH_COMMANDS and not saw_path: + raise SandboxPolicyDenied( + f"read-only sandbox denied command {executable!r} without a workspace path" + ) + return normalized + + def _resolve_path(self, path: str) -> Path: + raw = str(path or "").strip() + if not raw: + raise SandboxPolicyDenied("read-only sandbox requires a workspace path") + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = self._workspace_root / candidate + resolved = candidate.expanduser().resolve() + if resolved != self._workspace_root and self._workspace_root not in resolved.parents: + raise SandboxPolicyDenied(f"read-only sandbox denied path outside workspace: {raw!r}") + return resolved + + +__all__ = ["HarnessSandboxExecutor", "SandboxPolicyDenied"] diff --git a/ksadk/harness/tools.py b/ksadk/harness/tools.py new file mode 100644 index 00000000..2e3acfab --- /dev/null +++ b/ksadk/harness/tools.py @@ -0,0 +1,222 @@ +"""Tool catalog for one Harness runner.""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + +from ksadk.compat.adk_compat import Agent, InMemorySessionService, InvocationContext, ToolContext +from ksadk.harness.config import McpToolSpec +from ksadk.harness.sandbox import HarnessSandboxExecutor +from ksadk.mcp_runtime import MCPServerConfig, build_mcp_toolset + +ToolHandler = Callable[[dict[str, Any], str | None], Awaitable[Any]] + + +@dataclass(frozen=True) +class HarnessTool: + name: str + description: str + parameters: dict[str, Any] + handler: ToolHandler + source: str + + @property + def openai_schema(self) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + async def call(self, arguments: dict[str, Any], *, call_id: str | None = None) -> Any: + return await self.handler(arguments, call_id) + + +def sandbox_tools(sandbox: HarnessSandboxExecutor) -> list[HarnessTool]: + async def _read_file(arguments: dict[str, Any], call_id: str | None) -> Any: + del call_id + return await sandbox.read_file(str(arguments.get("path") or "")) + + async def _run_command(arguments: dict[str, Any], call_id: str | None) -> Any: + del call_id + return await sandbox.run_command(str(arguments.get("command") or "")) + + return [ + HarnessTool( + name="sandbox_read_file", + description="Read one UTF-8 file inside the Harness workspace.", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + "additionalProperties": False, + }, + handler=_read_file, + source="sandbox", + ), + HarnessTool( + name="sandbox_run_command", + description=( + "Run an allowlisted read-only command inside the Harness workspace. " + "Writes, network access, and dangerous process actions are denied." + ), + parameters={ + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + "additionalProperties": False, + }, + handler=_run_command, + source="sandbox", + ), + ] + + +async def load_mcp_tools(spec: McpToolSpec) -> tuple[Any, list[HarnessTool]]: + config = MCPServerConfig( + name=spec.name, + url=spec.url, + api_key=spec.api_key, + tool_filter=spec.tool_filter, + tool_name_prefix=spec.tool_name_prefix, + ) + toolset = build_mcp_toolset(config) + try: + native_tools = await toolset.get_tools_with_prefix() + except Exception as exc: + await toolset.close() + raise RuntimeError( + f"Harness MCP server {spec.name!r} at {spec.url!r} failed to start: {exc}" + ) from exc + + available_names = {str(tool.name) for tool in native_tools} + expected_names = { + f"{spec.tool_name_prefix}_{name}" if spec.tool_name_prefix else name + for name in spec.tool_filter + } + missing_names = sorted(expected_names - available_names) + if missing_names: + await toolset.close() + raise RuntimeError( + f"Harness MCP server {spec.name!r} at {spec.url!r} did not expose configured " + f"tool(s): {missing_names}" + ) + if not native_tools: + await toolset.close() + raise RuntimeError( + f"Harness MCP server {spec.name!r} at {spec.url!r} exposed no callable tools" + ) + + tools: list[HarnessTool] = [] + for native_tool in native_tools: + # ADK's declaration builder is private and changed across 1.x/2.x. + # MCP exposes the original public tool schema, which is stable across + # the supported versions. + raw_tool = getattr(native_tool, "raw_mcp_tool", None) + parameters = getattr(raw_tool, "inputSchema", None) + if parameters is None: + parameters = getattr(raw_tool, "input_schema", None) + if parameters is None: + parameters = getattr(native_tool, "input_schema", None) + if not isinstance(parameters, dict): + parameters = {"type": "object", "properties": {}} + parameters = _normalize_json_schema(parameters) + + async def _call( + arguments: dict[str, Any], + call_id: str | None, + *, + _native_tool=native_tool, + _tool_name=str(native_tool.name), + ) -> Any: + try: + context = await _new_tool_context(call_id or _tool_name) + result = await _native_tool.run_async(args=arguments, tool_context=context) + return _normalize_tool_result(result, context) + except Exception as exc: + raise RuntimeError( + f"Harness MCP tool {_tool_name!r} on server {spec.name!r} failed: {exc}" + ) from exc + + tools.append( + HarnessTool( + name=str(native_tool.name), + description=str(getattr(native_tool, "description", None) or "MCP tool"), + parameters=parameters, + handler=_call, + source=f"mcp:{spec.name}", + ) + ) + return toolset, tools + + +async def _new_tool_context(tool_name: str) -> Any: + """Build a real ADK ToolContext for a standalone MCP invocation.""" + service = InMemorySessionService() + session = await service.create_session( + app_name="ksadk-harness", + user_id="harness", + session_id=f"tool-{uuid.uuid4().hex}", + ) + invocation = InvocationContext( + session_service=service, + invocation_id=f"harness-tool-{tool_name}", + # ADK 1.34 requires an agent while 2.x makes it optional. Supplying a + # minimal public Agent keeps the same ToolContext construction on both. + agent=Agent(name="harness_tool", model="gemini-2.0-flash"), + session=session, + ) + return ToolContext(invocation_context=invocation, function_call_id=tool_name) + + +def _normalize_tool_result(result: Any, context: Any) -> Any: + """Keep MCP failures structured so the reasoner can produce a final answer.""" + confirmations = getattr(context.actions, "requested_tool_confirmations", {}) + if confirmations: + error = result.get("error") if isinstance(result, dict) else None + return { + "ok": False, + "confirmation_required": True, + "confirmation_ids": sorted(str(item) for item in confirmations), + "error": str(error or "tool confirmation is required"), + } + if isinstance(result, dict) and result.get("error") is not None: + return {"ok": False, "error": str(result["error"])} + return result + + +def tool_result_text(result: Any) -> str: + if isinstance(result, str): + return result + if isinstance(result, dict): + content = result.get("content") + if isinstance(content, list): + texts = [ + str(item.get("text")) + for item in content + if isinstance(item, dict) and item.get("text") is not None + ] + if texts: + return "\n".join(texts) + return json.dumps(result, ensure_ascii=False, default=str) + + +def _normalize_json_schema(value: Any) -> Any: + if isinstance(value, dict): + normalized = {key: _normalize_json_schema(item) for key, item in value.items()} + schema_type = normalized.get("type") + if isinstance(schema_type, str): + normalized["type"] = schema_type.lower() + return normalized + if isinstance(value, list): + return [_normalize_json_schema(item) for item in value] + return value + + +__all__ = ["HarnessTool", "load_mcp_tools", "sandbox_tools", "tool_result_text"] diff --git a/ksadk/hermes_terminal.py b/ksadk/hermes_terminal.py index 57e580f3..4e1b0360 100644 --- a/ksadk/hermes_terminal.py +++ b/ksadk/hermes_terminal.py @@ -5,6 +5,7 @@ import asyncio import contextlib import ctypes +import importlib import io import json import os @@ -123,7 +124,9 @@ def validate_terminal_exec_argv(argv: Iterable[str]) -> list[str]: return validate_exec_argv_with_policy(argv, policy=GENERIC_TERMINAL_EXEC_POLICY) -def build_terminal_exec_validator(policy: TerminalExecPolicy) -> Callable[[Iterable[str]], list[str]]: +def build_terminal_exec_validator( + policy: TerminalExecPolicy, +) -> Callable[[Iterable[str]], list[str]]: def _validator(argv: Iterable[str]) -> list[str]: return validate_exec_argv_with_policy(argv, policy=policy) @@ -190,30 +193,34 @@ def _raw_terminal(stdin: Any): @contextlib.contextmanager -def _windows_raw_terminal(stdin: Any, *, kernel32: Any | None = None, msvcrt_module: Any | None = None): +def _windows_raw_terminal( + stdin: Any, *, kernel32: Any | None = None, msvcrt_module: Any | None = None +): if not hasattr(stdin, "fileno") or not hasattr(stdin, "isatty") or not stdin.isatty(): yield return + resolved_kernel32: Any = kernel32 + resolved_msvcrt: Any = msvcrt_module try: - if kernel32 is None: - kernel32 = ctypes.windll.kernel32 - if msvcrt_module is None: - import msvcrt as msvcrt_module # type: ignore[no-redef] + if resolved_kernel32 is None: + resolved_kernel32 = getattr(ctypes, "windll").kernel32 + if resolved_msvcrt is None: + resolved_msvcrt = importlib.import_module("msvcrt") except Exception: yield return try: fd = stdin.fileno() - handle = msvcrt_module.get_osfhandle(fd) + handle = resolved_msvcrt.get_osfhandle(fd) except Exception: yield return original_mode = wintypes.DWORD() try: - if not kernel32.GetConsoleMode(handle, ctypes.byref(original_mode)): + if not resolved_kernel32.GetConsoleMode(handle, ctypes.byref(original_mode)): yield return except Exception: @@ -233,7 +240,7 @@ def _windows_raw_terminal(stdin: Any, *, kernel32: Any | None = None, msvcrt_mod ) try: - if not kernel32.SetConsoleMode(handle, raw_mode): + if not resolved_kernel32.SetConsoleMode(handle, raw_mode): yield return except Exception: @@ -244,14 +251,18 @@ def _windows_raw_terminal(stdin: Any, *, kernel32: Any | None = None, msvcrt_mod yield finally: with contextlib.suppress(Exception): - kernel32.SetConsoleMode(handle, int(original_mode.value)) + resolved_kernel32.SetConsoleMode(handle, int(original_mode.value)) -async def _connect_websocket(ws_url: str, headers: dict[str, str], ssl_context: ssl.SSLContext | None): +async def _connect_websocket( + ws_url: str, headers: dict[str, str], ssl_context: ssl.SSLContext | None +): try: import websockets except ImportError as e: - raise RuntimeError("missing dependency websockets, please install ksadk with websocket support") from e + raise RuntimeError( + "missing dependency websockets, please install ksadk with websocket support" + ) from e kwargs: dict[str, Any] = {"subprotocols": [TERMINAL_SUBPROTOCOL]} if ssl_context is not None: diff --git a/ksadk/identity/resolver.py b/ksadk/identity/resolver.py index 15f55c23..eff6fb40 100644 --- a/ksadk/identity/resolver.py +++ b/ksadk/identity/resolver.py @@ -31,11 +31,11 @@ class ResolvedIdentity: """AK/SK 反查到的身份信息。""" - user_uuid: Optional[str] # 子账号 UserId(X-Ksc-User-uuid 值);主账号 AK 时 None + user_uuid: Optional[str] # 子账号 UserId(X-Ksc-User-uuid 值);主账号 AK 时 None main_account_id: Optional[str] # 从 Krn 提取的主账号 ID - user_name: Optional[str] # 子账号 UserName(调试用) - krn: Optional[str] # 原始 Krn(调试用) - ak_fingerprint: str # sha256(AK)[:16],缓存 key + user_name: Optional[str] # 子账号 UserName(调试用) + krn: Optional[str] # 原始 Krn(调试用) + ak_fingerprint: str # sha256(AK)[:16],缓存 key # --------------------------------------------------------------------------- @@ -107,14 +107,20 @@ def _should_retry_intranet(error: Exception | None) -> bool: def _import_iam_sdk(): """惰性导入 ksyun IAM SDK,失败返回 None。""" try: - from ksyun.client.iam.v20151101.client import IamClient - from ksyun.client.iam.v20151101.models import ( + from ksyun.client.iam.v20151101.client import ( # type: ignore[import-untyped] + IamClient, + ) + from ksyun.client.iam.v20151101.models import ( # type: ignore[import-untyped] GetUserRequest, ListAllUserAccessKeysRequest, ) - from ksyun.common.credential import Credential - from ksyun.common.profile.client_profile import ClientProfile - from ksyun.common.profile.http_profile import HttpProfile + from ksyun.common.credential import Credential # type: ignore[import-untyped] + from ksyun.common.profile.client_profile import ( # type: ignore[import-untyped] + ClientProfile, + ) + from ksyun.common.profile.http_profile import ( # type: ignore[import-untyped] + HttpProfile, + ) except Exception as exc: logger.warning("导入 ksyun IAM SDK 失败: %s", exc) return None diff --git a/ksadk/ids.py b/ksadk/ids.py new file mode 100644 index 00000000..88f84a4a --- /dev/null +++ b/ksadk/ids.py @@ -0,0 +1,32 @@ +"""统一 id 铸造规范。 + +历史上 session / run(invocation) id 各层格式混杂: + +- agentengine-server hosted 会话: ``sess-<16 hex>``(conversation_service) +- ksadk 本地 session: 裸 ``<16 hex>``(sessions/base.generate_id) +- ksadk 后台 run: ``inv_<32 hex>``(server/app RunAgent) +- ksadk conversation turn: 裸 ``uuid4``(conversations/runtime) +- research-ui run_agent 模式: ``run_``(由 session id 推导) + +新代码统一走这里: + +- session id: ``sess-<16 hex>``,与 agentengine-server 对齐。 +- run/invocation id: ``run_<32 hex>``,每次 run 唯一且固定长度。 + +id 均为不透明字符串,仅在铸造时保证格式,消费方不做结构解析。 +""" + +from __future__ import annotations + +import uuid + + +def new_session_id() -> str: + """铸造 session id:``sess-<16 hex>``。""" + return f"sess-{uuid.uuid4().hex[:16]}" + + +def new_run_id(session_id: str | None = None) -> str: + """铸造固定长度 opaque run/invocation id:``run_<32 hex>``。""" + del session_id + return f"run_{uuid.uuid4().hex}" diff --git a/ksadk/knowledge_base/adk_tool.py b/ksadk/knowledge_base/adk_tool.py index 24b6ce53..345ec87d 100644 --- a/ksadk/knowledge_base/adk_tool.py +++ b/ksadk/knowledge_base/adk_tool.py @@ -36,7 +36,7 @@ def create_adk_tool(): 可直接注入到 ADK Agent 的工具对象 """ try: - from google.adk.tools import FunctionTool + from ksadk.compat.adk_compat import FunctionTool return FunctionTool(func=search_knowledge_base) except ImportError: diff --git a/ksadk/knowledge_base/client.py b/ksadk/knowledge_base/client.py index 51e10b40..48c30c3c 100644 --- a/ksadk/knowledge_base/client.py +++ b/ksadk/knowledge_base/client.py @@ -101,9 +101,13 @@ def _get_client(self): return self._aicp_client try: - from ksyun.common import credential - from ksyun.common.profile.client_profile import ClientProfile - from ksyun.common.profile.http_profile import HttpProfile + from ksyun.common import credential # type: ignore[import-untyped] + from ksyun.common.profile.client_profile import ( # type: ignore[import-untyped] + ClientProfile, + ) + from ksyun.common.profile.http_profile import ( # type: ignore[import-untyped] + HttpProfile, + ) except ImportError: raise ImportError( "kingsoftcloud-sdk-python is required for knowledge base. " @@ -140,9 +144,7 @@ def _get_client(self): client_profile = ClientProfile() client_profile.httpProfile = http_profile - self._aicp_client = aicp_module.AicpClient( - cred, self.region, profile=client_profile - ) + self._aicp_client = aicp_module.AicpClient(cred, self.region, profile=client_profile) # 强制覆写 API 版本为 RetrieveKnowledge 所需的 2025-11-14 # SDK 的 _apiVersion 由导入的模块版本决定,可能不匹配 @@ -159,19 +161,20 @@ def _build_params(self, query: str, top_k: Optional[int] = None) -> dict: """构建 RetrieveKnowledge 请求参数 (JSON 嵌套格式)""" effective_top_k = top_k if top_k is not None else self.top_k - params = { + retrieval_model: dict[str, Any] = { + "SearchMethod": self.search_method, + "TopK": effective_top_k, + "RerankingEnable": self.reranking_enable, + } + params: dict[str, Any] = { "DatasetId": self.dataset_id, "Query": query, - "RetrievalModel": { - "SearchMethod": self.search_method, - "TopK": effective_top_k, - "RerankingEnable": self.reranking_enable, - }, + "RetrievalModel": retrieval_model, } if self.score_threshold_enabled: - params["RetrievalModel"]["ScoreThresholdEnabled"] = True - params["RetrievalModel"]["ScoreThreshold"] = self.score_threshold + retrieval_model["ScoreThresholdEnabled"] = True + retrieval_model["ScoreThreshold"] = self.score_threshold return params @@ -205,9 +208,7 @@ def _parse_response(self, response: str) -> List[KnowledgeBaseResult]: return results - def search( - self, query: str, top_k: Optional[int] = None - ) -> List[KnowledgeBaseResult]: + def search(self, query: str, top_k: Optional[int] = None) -> List[KnowledgeBaseResult]: """检索知识库 Args: @@ -221,18 +222,14 @@ def search( params = self._build_params(query, top_k) logger.info( - f"Searching knowledge base: dataset_id={self.dataset_id}, " - f"query='{query[:50]}'" + f"Searching knowledge base: dataset_id={self.dataset_id}, " f"query='{query[:50]}'" ) try: - response = client.call( - "RetrieveKnowledge", params, options={"IsPostJson": True} - ) + response = client.call("RetrieveKnowledge", params, options={"IsPostJson": True}) results = self._parse_response(response) logger.info( - f"Knowledge base returned {len(results)} results " - f"for query='{query[:50]}'" + f"Knowledge base returned {len(results)} results " f"for query='{query[:50]}'" ) return results except Exception as e: @@ -252,8 +249,7 @@ def from_env(cls) -> "KnowledgeBaseClient": dataset_id = os.environ.get("KSADK_KB_DATASET_ID", "") if not dataset_id: raise ValueError( - "KSADK_KB_DATASET_ID environment variable is required " - "to enable knowledge base." + "KSADK_KB_DATASET_ID environment variable is required " "to enable knowledge base." ) access_key = ( @@ -283,9 +279,7 @@ def from_env(cls) -> "KnowledgeBaseClient": endpoint=connection["endpoint"], scheme=connection["scheme"], top_k=int(os.environ.get("KSADK_KB_TOP_K", "5")), - search_method=os.environ.get( - "KSADK_KB_SEARCH_METHOD", "intelligence_search" - ), + search_method=os.environ.get("KSADK_KB_SEARCH_METHOD", "intelligence_search"), score_threshold=score_threshold, score_threshold_enabled=score_threshold_enabled, reranking_enable=reranking_enable, diff --git a/ksadk/managed_runtime.py b/ksadk/managed_runtime.py new file mode 100644 index 00000000..c5e81bfa --- /dev/null +++ b/ksadk/managed_runtime.py @@ -0,0 +1,188 @@ +"""Managed runtime configuration and version resolution.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version +from typing import Any + + +class ManagedRuntimeError(ValueError): + """Raised when a managed runtime contract cannot be resolved safely.""" + + +@dataclass(frozen=True) +class ResolvedRuntime: + name: str + version: str + source: str + package_requirement: str = "" + + +def runtime_config(config: dict[str, Any]) -> tuple[str, str]: + value = config.get("runtime") + if not isinstance(value, dict): + raise ManagedRuntimeError("ManagedRuntime 项目缺少 runtime 配置") + name = str(value.get("name") or "").strip().lower() + requested_version = str(value.get("version") or "").strip() + if not name: + raise ManagedRuntimeError("ManagedRuntime 项目缺少 runtime.name") + return name, requested_version + + +def extract_bootstrap_runtime( + bootstrap: dict[str, Any] | None, + runtime_name: str, +) -> ResolvedRuntime | None: + if not isinstance(bootstrap, dict): + return None + configs = bootstrap.get("configs") or bootstrap.get("Configs") + if not isinstance(configs, dict): + return None + version = str( + configs.get("runtime.default_version") + or configs.get(f"runtime.{runtime_name}.default_version") + or "" + ).strip() + if not version: + return None + package = str( + configs.get("runtime.package") + or configs.get(f"runtime.{runtime_name}.package") + or "" + ).strip() + return ResolvedRuntime( + name=runtime_name, + version=version, + source="server", + package_requirement=package, + ) + + +async def resolve_managed_runtime( + config: dict[str, Any], + *, + region: str, + bootstrap: dict[str, Any] | None = None, +) -> ResolvedRuntime: + name, requested_version = runtime_config(config) + if requested_version: + return ResolvedRuntime(name=name, version=requested_version, source="manifest") + + resolved = extract_bootstrap_runtime(bootstrap, name) + if resolved is not None: + return resolved + + if bootstrap is None: + from ksadk.api.client import AgentEngineClient + from ksadk.version import VERSION as CLI_VERSION + + try: + async with AgentEngineClient(region=region) as client: + payload = await client.get_client_bootstrap_config( + product="agentengine", + framework=name, + region=region, + client_type="cli", + client_version=CLI_VERSION, + ignore_dry_run=True, + ) + except Exception as exc: + raise ManagedRuntimeError( + "无法从 AgentEngine 获取 ManagedRuntime 默认版本;" + "请连接服务端或在 agentengine.yaml 中显式配置 runtime.version" + ) from exc + resolved = extract_bootstrap_runtime(payload, name) + if resolved is not None: + return resolved + + raise ManagedRuntimeError( + f"AgentEngine 未下发 {name} 的默认 Runtime 版本;" + "请配置服务端 Runtime catalog 或显式设置 runtime.version" + ) + + +def installed_runtime_version(runtime_name: str) -> str: + package_name = {"codex": "openai-codex"}.get(runtime_name, runtime_name) + try: + return package_version(package_name) + except PackageNotFoundError: + return "" + + +def validate_installed_runtime(resolved: ResolvedRuntime) -> str: + installed = installed_runtime_version(resolved.name) + if not installed: + raise ManagedRuntimeError( + f"本地缺少 {resolved.name} runtime;请安装 `pip install 'ksadk[{resolved.name}]'`" + ) + if resolved.version and installed != resolved.version: + raise ManagedRuntimeError( + f"本地 {resolved.name} runtime 版本为 {installed}," + f"配置要求 {resolved.version};请安装匹配版本后重试" + ) + validate_runtime_binary(resolved) + return installed + + +def validate_runtime_binary(resolved: ResolvedRuntime) -> str: + if resolved.name != "codex": + return "" + try: + from codex_cli_bin import bundled_codex_path + + codex_bin = bundled_codex_path() + completed = subprocess.run( + [str(codex_bin), "--version"], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + except (ImportError, OSError, subprocess.SubprocessError) as exc: + raise ManagedRuntimeError( + "本地 Codex 平台二进制不可用;请重新安装 `pip install --force-reinstall " + "'ksadk[codex]'`" + ) from exc + output = f"{completed.stdout}\n{completed.stderr}".strip() + if resolved.version and resolved.version not in output: + raise ManagedRuntimeError( + f"Codex CLI 版本输出为 {output or '(empty)'},配置要求 {resolved.version}" + ) + return output + + +async def resolve_local_managed_runtime( + config: dict[str, Any], + *, + region: str, +) -> ResolvedRuntime: + """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 + + try: + resolved = await resolve_managed_runtime(config, region=region) + except ManagedRuntimeError: + resolved = None + if resolved is not None: + validate_installed_runtime(resolved) + return resolved + + installed = installed_runtime_version(name) + if not installed: + raise ManagedRuntimeError( + f"本地缺少 {name} runtime;请安装 `pip install 'ksadk[{name}]'`" + ) + resolved = ResolvedRuntime( + name=name, + version=installed, + source="installed-unlocked", + ) + validate_runtime_binary(resolved) + return resolved diff --git a/ksadk/markdown.py b/ksadk/markdown.py index 05eb6310..5d5853a9 100644 --- a/ksadk/markdown.py +++ b/ksadk/markdown.py @@ -2,7 +2,6 @@ from typing import Any - _FENCE_MARKER_CHARS = ("`", "~") _LIST_PREFIXES = ("- ", "* ", "+ ") @@ -157,7 +156,11 @@ def _is_closing_fence(candidate: str, open_marker: str) -> bool: def _is_table_line(stripped_line: str) -> bool: - return stripped_line.startswith("|") and stripped_line.endswith("|") and stripped_line.count("|") >= 2 + return ( + stripped_line.startswith("|") + and stripped_line.endswith("|") + and stripped_line.count("|") >= 2 + ) def _is_list_line(stripped_line: str) -> bool: diff --git a/ksadk/mcp_runtime/__init__.py b/ksadk/mcp_runtime/__init__.py index 4523b5b0..096ca16c 100644 --- a/ksadk/mcp_runtime/__init__.py +++ b/ksadk/mcp_runtime/__init__.py @@ -1,18 +1,19 @@ from __future__ import annotations +import inspect import json import os -import inspect from dataclasses import dataclass -from typing import Any, Sequence +from typing import Any, Coroutine, Sequence, cast from urllib.parse import urlparse import httpx -from google.adk.tools.mcp_tool.mcp_session_manager import ( + +from ksadk.compat.adk_compat import ( CheckableMcpHttpClientFactory, + McpToolset, StreamableHTTPConnectionParams, ) -from google.adk.tools.mcp_tool.mcp_toolset import McpToolset MCP_TOOLSET_KEY_ATTR = "_ksadk_mcp_toolset_key" @@ -64,7 +65,9 @@ def build_connection_params( httpx_client_factory: CheckableMcpHttpClientFactory | None = None, ) -> StreamableHTTPConnectionParams: kwargs: dict[str, Any] = {"url": config.url, "headers": config.headers} - kwargs["httpx_client_factory"] = httpx_client_factory or _default_httpx_client_factory(config.url) + kwargs["httpx_client_factory"] = httpx_client_factory or _default_httpx_client_factory( + config.url + ) return StreamableHTTPConnectionParams(**kwargs) @@ -146,7 +149,7 @@ def _mcp_toolset_tools_sync(toolset: Any) -> list[Any]: try: asyncio.get_running_loop() except RuntimeError: - result = asyncio.run(result) + result = asyncio.run(cast(Coroutine[Any, Any, Any], result)) else: close = getattr(result, "close", None) if callable(close): diff --git a/ksadk/memory/__init__.py b/ksadk/memory/__init__.py index b83cf9a5..bd3cf65f 100644 --- a/ksadk/memory/__init__.py +++ b/ksadk/memory/__init__.py @@ -13,9 +13,9 @@ from typing import TYPE_CHECKING -from ksadk.memory.manager import MemoryManager, get_memory_manager from ksadk.memory.backends.base import BaseMemoryBackend from ksadk.memory.backends.memory import InMemoryBackend +from ksadk.memory.manager import MemoryManager, get_memory_manager if TYPE_CHECKING: from ksadk.memory.service import LongTermMemoryService diff --git a/ksadk/memory/adk/__init__.py b/ksadk/memory/adk/__init__.py index 4e3bc852..d0d9eb06 100644 --- a/ksadk/memory/adk/__init__.py +++ b/ksadk/memory/adk/__init__.py @@ -39,10 +39,12 @@ def __getattr__(name): if name == "ShortTermMemory": from ksadk.memory.adk.short_term_memory import ShortTermMemory + return ShortTermMemory if name == "LongTermMemory": from ksadk.memory.adk.long_term_memory import LongTermMemory + return LongTermMemory raise AttributeError(f"module 'ksadk.memory.adk' has no attribute '{name}'") diff --git a/ksadk/memory/adk/backends/base_ltm_backend.py b/ksadk/memory/adk/backends/base_ltm_backend.py index 0cd289b1..0e62ea01 100644 --- a/ksadk/memory/adk/backends/base_ltm_backend.py +++ b/ksadk/memory/adk/backends/base_ltm_backend.py @@ -21,9 +21,7 @@ class BaseLongTermMemoryBackend(ABC, BaseModel): index: str = "" @abstractmethod - def save_memory( - self, user_id: str, event_strings: List[str], **kwargs - ) -> bool: + def save_memory(self, user_id: str, event_strings: List[str], **kwargs) -> bool: """保存记忆 Args: @@ -36,9 +34,7 @@ def save_memory( pass @abstractmethod - def search_memory( - self, user_id: str, query: str, top_k: int = 5, **kwargs - ) -> List[str]: + def search_memory(self, user_id: str, query: str, top_k: int = 5, **kwargs) -> List[str]: """检索记忆 Args: diff --git a/ksadk/memory/adk/backends/http_ltm_backend.py b/ksadk/memory/adk/backends/http_ltm_backend.py index 3186d4d3..76998dee 100644 --- a/ksadk/memory/adk/backends/http_ltm_backend.py +++ b/ksadk/memory/adk/backends/http_ltm_backend.py @@ -12,7 +12,7 @@ from typing import List, Optional import httpx -from pydantic import ConfigDict, Field +from pydantic import ConfigDict from ksadk.memory.adk.backends.base_ltm_backend import BaseLongTermMemoryBackend @@ -52,12 +52,10 @@ class HttpLTMBackend(BaseLongTermMemoryBackend): def model_post_init(self, __context) -> None: if not self.base_url: logger.warning( - "HttpLTMBackend: base_url is empty. " - "Set KSADK_LTM_HTTP_URL environment variable." + "HttpLTMBackend: base_url is empty. " "Set KSADK_LTM_HTTP_URL environment variable." ) logger.info( - f"HttpLTMBackend initialized: base_url={self.base_url[:50]}... " - f"index={self.index}" + f"HttpLTMBackend initialized: base_url={self.base_url[:50]}... " f"index={self.index}" ) @property @@ -75,9 +73,7 @@ def client(self) -> httpx.Client: ) return self._client - def save_memory( - self, user_id: str, event_strings: List[str], **kwargs - ) -> bool: + def save_memory(self, user_id: str, event_strings: List[str], **kwargs) -> bool: """保存记忆到远程服务 TODO: 对接金山云记忆服务 API @@ -105,24 +101,20 @@ def save_memory( response.raise_for_status() logger.info( - f"Saved {len(event_strings)} events to remote memory service " - f"for user={user_id}" + f"Saved {len(event_strings)} events to remote memory service " f"for user={user_id}" ) return True except httpx.HTTPStatusError as e: logger.error( - f"HTTP error saving memory: {e.response.status_code} " - f"{e.response.text[:200]}" + f"HTTP error saving memory: {e.response.status_code} " f"{e.response.text[:200]}" ) return False except Exception as e: logger.error(f"Error saving memory to remote service: {e}") return False - def search_memory( - self, user_id: str, query: str, top_k: int = 5, **kwargs - ) -> List[str]: + def search_memory(self, user_id: str, query: str, top_k: int = 5, **kwargs) -> List[str]: """从远程服务检索记忆 TODO: 对接金山云记忆服务 API @@ -140,9 +132,7 @@ def search_memory( } """ if not self.base_url: - logger.warning( - "HttpLTMBackend: base_url not configured, return empty results." - ) + logger.warning("HttpLTMBackend: base_url not configured, return empty results.") return [] try: @@ -158,7 +148,10 @@ def search_memory( response.raise_for_status() data = response.json() - memories = data.get("memories", []) + raw_memories = data.get("memories", []) + memories = ( + [str(memory) for memory in raw_memories] if isinstance(raw_memories, list) else [] + ) logger.info( f"Retrieved {len(memories)} memories from remote service " @@ -168,8 +161,7 @@ def search_memory( except httpx.HTTPStatusError as e: logger.error( - f"HTTP error searching memory: {e.response.status_code} " - f"{e.response.text[:200]}" + f"HTTP error searching memory: {e.response.status_code} " f"{e.response.text[:200]}" ) return [] except Exception as e: diff --git a/ksadk/memory/adk/backends/inmemory_ltm_backend.py b/ksadk/memory/adk/backends/inmemory_ltm_backend.py index 50e199ca..b14dbaa2 100644 --- a/ksadk/memory/adk/backends/inmemory_ltm_backend.py +++ b/ksadk/memory/adk/backends/inmemory_ltm_backend.py @@ -8,6 +8,8 @@ from collections import defaultdict from typing import List +from pydantic import PrivateAttr + from ksadk.memory.adk.backends.base_ltm_backend import BaseLongTermMemoryBackend logger = logging.getLogger(__name__) @@ -27,19 +29,13 @@ class InMemoryLTMBackend(BaseLongTermMemoryBackend): ``` """ - # Pydantic v2 不允许直接声明 mutable default,使用 model_post_init - _storage: dict = None + _storage: defaultdict[str, list[str]] = PrivateAttr(default_factory=lambda: defaultdict(list)) def model_post_init(self, __context) -> None: # {user_id: [event_string, ...]} - self._storage = defaultdict(list) - logger.info( - f"InMemoryLTMBackend initialized: index={self.index}" - ) + logger.info(f"InMemoryLTMBackend initialized: index={self.index}") - def save_memory( - self, user_id: str, event_strings: List[str], **kwargs - ) -> bool: + def save_memory(self, user_id: str, event_strings: List[str], **kwargs) -> bool: """保存记忆到内存""" if not event_strings: return True @@ -51,9 +47,7 @@ def save_memory( ) return True - def search_memory( - self, user_id: str, query: str, top_k: int = 5, **kwargs - ) -> List[str]: + def search_memory(self, user_id: str, query: str, top_k: int = 5, **kwargs) -> List[str]: """基于关键词匹配检索记忆 简单实现:对 query 分词后,按匹配关键词数量排序。 @@ -68,7 +62,7 @@ def search_memory( # 按字符分词(支持中英文混合) query_terms = query_lower.split() - scored = [] + scored: list[tuple[int, str]] = [] for memory in user_memories: memory_lower = memory.lower() # 计算匹配分数:完整 query 匹配得高分,部分关键词匹配得低分 diff --git a/ksadk/memory/adk/backends/sdk_ltm_backend.py b/ksadk/memory/adk/backends/sdk_ltm_backend.py index e6a7716c..d227de3c 100644 --- a/ksadk/memory/adk/backends/sdk_ltm_backend.py +++ b/ksadk/memory/adk/backends/sdk_ltm_backend.py @@ -105,9 +105,13 @@ def _get_client(self): return self._aicp_client try: - from ksyun.common import credential - from ksyun.common.profile.client_profile import ClientProfile - from ksyun.common.profile.http_profile import HttpProfile + from ksyun.common import credential # type: ignore[import-untyped] + from ksyun.common.profile.client_profile import ( # type: ignore[import-untyped] + ClientProfile, + ) + from ksyun.common.profile.http_profile import ( # type: ignore[import-untyped] + HttpProfile, + ) except ImportError: raise ImportError( "kingsoftcloud-sdk-python is required for SDK memory backend. " @@ -144,9 +148,7 @@ def _get_client(self): client_profile = ClientProfile() client_profile.httpProfile = http_profile - self._aicp_client = aicp_module.AicpClient( - cred, self.region, profile=client_profile - ) + self._aicp_client = aicp_module.AicpClient(cred, self.region, profile=client_profile) # 强制覆写 API 版本为记忆库 API 所需的 2025-11-14 self._aicp_client._apiVersion = "2025-11-14" @@ -181,12 +183,14 @@ def _build_conversation(self, event_strings: list[str]) -> list: if not text: continue - conversation.append({ - "Role": role, - "CreatedAt": int(time.time() * 1000), - "MessageId": str(uuid.uuid4()), - "Content": [{"Type": "input_text", "Text": text}], - }) + conversation.append( + { + "Role": role, + "CreatedAt": int(time.time() * 1000), + "MessageId": str(uuid.uuid4()), + "Content": [{"Type": "input_text", "Text": text}], + } + ) return conversation def _effective_memory_collection_id(self) -> str: @@ -195,9 +199,7 @@ def _effective_memory_collection_id(self) -> str: def _effective_scene_id(self) -> str: return self.scene_id or DEFAULT_SCENE_ID - def save_memory( - self, user_id: str, event_strings: list[str], **kwargs - ) -> bool: + def save_memory(self, user_id: str, event_strings: list[str], **kwargs) -> bool: """调用 CreateMemorySdk 写入记忆 Args: @@ -221,7 +223,10 @@ def save_memory( logger.info("No valid conversation items to save") return True - metadata = kwargs.get("metadata") if isinstance(kwargs.get("metadata"), dict) else {} + metadata_value = kwargs.get("metadata") + metadata: dict[str, Any] = ( + dict(metadata_value) if isinstance(metadata_value, dict) else {} + ) agent_id = metadata.get("agent_id") or self.agent_id session_id = metadata.get("session_id") or kwargs.get("session_id") params = { @@ -241,9 +246,7 @@ def save_memory( f"user_id={user_id}, messages={len(conversation)}" ) - response = client.call( - "CreateMemorySdk", params, options={"IsPostJson": True} - ) + response = client.call("CreateMemorySdk", params, options={"IsPostJson": True}) self.last_create_response = self._parse_json_response(response) or {} self.last_session_status = { "SessionId": session_id, @@ -252,8 +255,7 @@ def save_memory( } logger.info( - f"Saved {len(conversation)} messages to AICP memory service " - f"for user={user_id}" + f"Saved {len(conversation)} messages to AICP memory service " f"for user={user_id}" ) return True @@ -262,9 +264,7 @@ def save_memory( logger.error(f"CreateMemorySdk failed: {e}") return False - def search_memory( - self, user_id: str, query: str, top_k: int = 5, **kwargs - ) -> list[str]: + def search_memory(self, user_id: str, query: str, top_k: int = 5, **kwargs) -> list[str]: """调用 QueryMemorySdk 检索记忆 Args: @@ -306,9 +306,7 @@ def search_memory( f"user_id={user_id}, query='{query[:50]}'" ) - response = client.call( - "QueryMemorySdk", params, options={"IsPostJson": True} - ) + response = client.call("QueryMemorySdk", params, options={"IsPostJson": True}) # 解析响应 memories = self._parse_query_response(response) @@ -443,12 +441,7 @@ def _parse_memory_item(self, item: Any) -> list[str]: parsed.extend(self._parse_memory_item(memory)) return parsed - text = ( - item.get("Content") - or item.get("Text") - or item.get("Memory") - or item.get("Data") - ) + text = item.get("Content") or item.get("Text") or item.get("Memory") or item.get("Data") if text is None: return [] if isinstance(text, (dict, list)): diff --git a/ksadk/memory/adk/long_term_memory.py b/ksadk/memory/adk/long_term_memory.py index 1161a249..4917a169 100644 --- a/ksadk/memory/adk/long_term_memory.py +++ b/ksadk/memory/adk/long_term_memory.py @@ -23,19 +23,19 @@ import json import logging import os -from typing import Any, List, Literal +from typing import Any, List, Literal, Optional, cast -from google.adk.events.event import Event -from google.adk.memory.base_memory_service import ( +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr +from typing_extensions import Union, override + +from ksadk.compat.adk_compat import ( BaseMemoryService, + Event, + MemoryEntry, SearchMemoryResponse, + Session, ) -from google.adk.memory.memory_entry import MemoryEntry -from google.adk.sessions import Session -from google.genai import types -from pydantic import BaseModel, ConfigDict, Field -from typing_extensions import Union, override - +from ksadk.compat.adk_compat import genai_types as types from ksadk.memory.adk.backends.base_ltm_backend import BaseLongTermMemoryBackend from ksadk.memory.service import LongTermMemoryService @@ -83,8 +83,8 @@ class LongTermMemory(BaseMemoryService, BaseModel): index: str = "" app_name: str = "" - _backend: BaseLongTermMemoryBackend = None - _service: LongTermMemoryService = None + _backend: Optional[BaseLongTermMemoryBackend] = PrivateAttr(default=None) + _service: Optional[LongTermMemoryService] = PrivateAttr(default=None) model_config = ConfigDict(arbitrary_types_allowed=True) @@ -105,14 +105,18 @@ def model_post_init(self, __context: Any) -> None: ) def _get_service(self) -> LongTermMemoryService: - if self._service is None: + service = self._service + if service is None: self.model_post_init(None) - if self._backend is not None and self._service._backend is not self._backend: - self._service.backend = self._backend - self._service._backend = self._backend - self._service.index = getattr(self._backend, "index", self._service.index) - self.index = self._service.index - return self._service + service = self._service + if service is None: + raise RuntimeError("LongTermMemory service initialization failed") + if self._backend is not None and service._backend is not self._backend: + service.backend = self._backend + service._backend = self._backend + service.index = getattr(self._backend, "index", service.index) + self.index = service.index + return service def _filter_and_convert_events(self, events: List[Event]) -> List[str]: """过滤并序列化 session 事件 @@ -157,9 +161,7 @@ async def add_session_to_memory( event_strings = self._filter_and_convert_events(session.events) if not event_strings: - logger.info( - f"No user events to save for session={session.id}" - ) + logger.info(f"No user events to save for session={session.id}") return logger.info( @@ -200,9 +202,7 @@ async def search_memory( query=query, top_k=self.top_k, user_id=user_id ) except Exception as e: - logger.error( - f"Error during memory search: {e}. Returning empty results." - ) + logger.error(f"Error during memory search: {e}. Returning empty results.") # 转换为 MemoryEntry 格式 memory_events = [] @@ -213,9 +213,7 @@ async def search_memory( text = memory_dict["parts"][0]["text"] role = memory_dict.get("role", "user") except (KeyError, IndexError): - logger.warning( - f"Non-standard memory format: {memory[:100]}. Skipping." - ) + logger.warning(f"Non-standard memory format: {memory[:100]}. Skipping.") continue except json.JSONDecodeError: # 非 JSON 格式的记忆字符串,直接作为 text @@ -225,9 +223,7 @@ async def search_memory( memory_events.append( MemoryEntry( author="user", - content=types.Content( - parts=[types.Part(text=text)], role=role - ), + content=types.Content(parts=[types.Part(text=text)], role=role), ) ) @@ -256,16 +252,16 @@ def from_env( KSADK_LTM_INDEX: 索引名称 """ service = LongTermMemoryService.from_env(app_name=app_name, backend=backend) - backend_name = ( - backend - or os.environ.get("KSADK_LTM_BACKEND", "local") - ) + backend_name = backend or os.environ.get("KSADK_LTM_BACKEND", "local") backend_config = dict(service.backend_config) if "index" in backend_config and backend_name != "local": backend_config.pop("index", None) + if backend_name not in {"local", "http", "sdk"}: + raise ValueError(f"Unsupported long-term memory backend: {backend_name}") + normalized_backend = cast(Literal["local", "http", "sdk"], backend_name) return cls( - backend=backend_name, + backend=normalized_backend, backend_config=backend_config, top_k=service.top_k, index=service.index, diff --git a/ksadk/memory/adk/resilient_session_service.py b/ksadk/memory/adk/resilient_session_service.py index a3356810..9e93171e 100644 --- a/ksadk/memory/adk/resilient_session_service.py +++ b/ksadk/memory/adk/resilient_session_service.py @@ -4,15 +4,14 @@ import logging from typing import Any, Optional -from google.adk.events.event import Event -from google.adk.sessions import InMemorySessionService -from google.adk.sessions.base_session_service import ( +from ksadk.compat.adk_compat import ( BaseSessionService, + Event, GetSessionConfig, + InMemorySessionService, ListSessionsResponse, + Session, ) -from google.adk.sessions.session import Session - from ksadk.sessions.resilience import is_session_backend_failure logger = logging.getLogger(__name__) diff --git a/ksadk/memory/adk/short_term_memory.py b/ksadk/memory/adk/short_term_memory.py index 9f5490a0..6fcb15fc 100644 --- a/ksadk/memory/adk/short_term_memory.py +++ b/ksadk/memory/adk/short_term_memory.py @@ -24,14 +24,15 @@ import logging import os -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, cast -from google.adk.sessions import ( +from pydantic import BaseModel, PrivateAttr + +from ksadk.compat.adk_compat import ( BaseSessionService, InMemorySessionService, Session, ) -from pydantic import BaseModel, PrivateAttr logger = logging.getLogger(__name__) @@ -119,14 +120,9 @@ def model_post_init(self, __context: Any) -> None: logger.info("ShortTermMemory: using InMemorySessionService") case "sqlite": - db_url = _normalize_database_url( - f"sqlite:///{self.local_database_path}" - ) + db_url = _normalize_database_url(f"sqlite:///{self.local_database_path}") self._init_database_service(db_url) - logger.info( - f"ShortTermMemory: using SQLite at " - f"{self.local_database_path}" - ) + logger.info(f"ShortTermMemory: using SQLite at " f"{self.local_database_path}") case "database": if not self.db_url: @@ -148,8 +144,7 @@ def _init_database_service(self, db_url: str) -> None: """初始化数据库 SessionService""" normalized_db_url = _normalize_database_url(db_url) try: - from google.adk.sessions import DatabaseSessionService - + from ksadk.compat.adk_compat import DatabaseSessionService from ksadk.memory.adk.resilient_session_service import ResilientADKSessionService from ksadk.sessions.resilience import session_backend_timeout_seconds @@ -165,8 +160,7 @@ def _init_database_service(self, db_url: str) -> None: DatabaseSessionService(db_url=normalized_db_url, **service_kwargs) ) logger.info( - f"ShortTermMemory: using DatabaseSessionService " - f"({normalized_db_url[:30]}...)" + f"ShortTermMemory: using DatabaseSessionService " f"({normalized_db_url[:30]}...)" ) except ImportError: logger.warning( @@ -218,8 +212,7 @@ async def create_session( ) if session: logger.debug( - f"Session {session_id} already exists " - f"(app={app_name}, user={user_id})" + f"Session {session_id} already exists " f"(app={app_name}, user={user_id})" ) return session except Exception as e: @@ -238,10 +231,7 @@ async def create_session( app_name=app_name, user_id=user_id, ) - logger.info( - f"Created session: id={session.id}, " - f"app={app_name}, user={user_id}" - ) + logger.info(f"Created session: id={session.id}, " f"app={app_name}, user={user_id}") return session except Exception as e: logger.error(f"Failed to create session: {e}") @@ -297,8 +287,9 @@ def from_env(cls) -> "ShortTermMemory": else: backend = "local" + backend_name = cast(Literal["local", "sqlite", "database"], backend) return cls( - backend=backend, + backend=backend_name, db_url=db_url, local_database_path=db_path, ) diff --git a/ksadk/memory/adk_tool.py b/ksadk/memory/adk_tool.py index 5a20bd56..a9e5a13c 100644 --- a/ksadk/memory/adk_tool.py +++ b/ksadk/memory/adk_tool.py @@ -21,11 +21,12 @@ def save_memory(content: str) -> dict: def create_adk_tool(): try: - from google.adk.tools import FunctionTool + from ksadk.compat.adk_compat import FunctionTool return FunctionTool(func=save_memory) except ImportError: logger.warning( - "google-adk not installed, returning raw function as tool. Install with: pip install ksadk[adk]" + "google-adk not installed, returning raw function as tool. " + "Install with: pip install ksadk[adk]" ) return save_memory diff --git a/ksadk/memory/backends/base.py b/ksadk/memory/backends/base.py index c6e2813c..01107882 100644 --- a/ksadk/memory/backends/base.py +++ b/ksadk/memory/backends/base.py @@ -1,29 +1,29 @@ """Base Memory Backend - 抽象基类""" from abc import ABC, abstractmethod -from typing import Any, Optional, List, Dict from datetime import timedelta +from typing import Any, Dict, List, Optional class BaseMemoryBackend(ABC): """记忆存储后端抽象基类 - + 所有后端实现必须继承此类并实现以下方法。 """ - + @abstractmethod def get(self, key: str, session_id: Optional[str] = None) -> Optional[Any]: """获取值 - + Args: key: 键名 session_id: 可选的会话 ID,用于隔离不同会话的数据 - + Returns: 存储的值,不存在返回 None """ pass - + @abstractmethod def set( self, @@ -33,43 +33,43 @@ def set( ttl: Optional[timedelta] = None, ) -> bool: """设置值 - + Args: key: 键名 value: 值 (会自动序列化) session_id: 可选的会话 ID ttl: 可选的过期时间 - + Returns: 是否成功 """ pass - + @abstractmethod def delete(self, key: str, session_id: Optional[str] = None) -> bool: """删除键 - + Args: key: 键名 session_id: 可选的会话 ID - + Returns: 是否成功 """ pass - + @abstractmethod def exists(self, key: str, session_id: Optional[str] = None) -> bool: """检查键是否存在""" pass - + @abstractmethod def clear_session(self, session_id: str) -> bool: """清除整个会话的所有数据""" pass - + # ===== 消息历史相关 (短期记忆) ===== - + @abstractmethod def add_message( self, @@ -80,7 +80,7 @@ def add_message( ) -> bool: """添加消息到会话历史""" pass - + @abstractmethod def get_messages( self, @@ -88,16 +88,16 @@ def get_messages( limit: Optional[int] = None, ) -> List[Dict]: """获取会话消息历史 - + Args: session_id: 会话 ID limit: 最近 N 条消息,None 表示全部 - + Returns: 消息列表 [{role, content, timestamp, ...}] """ pass - + def close(self) -> None: """关闭连接 (可选实现)""" pass diff --git a/ksadk/memory/backends/memory.py b/ksadk/memory/backends/memory.py index dff9a59c..d50bc00d 100644 --- a/ksadk/memory/backends/memory.py +++ b/ksadk/memory/backends/memory.py @@ -1,49 +1,48 @@ """In-Memory Backend - 内存存储 (开发/测试用)""" -import json -from datetime import datetime, timedelta -from typing import Any, Optional, List, Dict from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional from ksadk.memory.backends.base import BaseMemoryBackend class InMemoryBackend(BaseMemoryBackend): """内存存储后端 - + 适用于开发和测试,数据在进程退出后丢失。 """ - + def __init__(self): # {session_id: {key: (value, expires_at)}} self._data: Dict[str, Dict[str, tuple]] = defaultdict(dict) # {session_id: [messages]} self._messages: Dict[str, List[Dict]] = defaultdict(list) - + def _make_key(self, key: str, session_id: Optional[str]) -> tuple: """生成复合键""" sid = session_id or "__global__" return sid, key - + def _is_expired(self, expires_at: Optional[datetime]) -> bool: if expires_at is None: return False return datetime.utcnow() > expires_at - + def get(self, key: str, session_id: Optional[str] = None) -> Optional[Any]: sid, k = self._make_key(key, session_id) - + if sid not in self._data or k not in self._data[sid]: return None - + value, expires_at = self._data[sid][k] - + if self._is_expired(expires_at): del self._data[sid][k] return None - + return value - + def set( self, key: str, @@ -52,32 +51,32 @@ def set( ttl: Optional[timedelta] = None, ) -> bool: sid, k = self._make_key(key, session_id) - + expires_at = None if ttl: expires_at = datetime.utcnow() + ttl - + self._data[sid][k] = (value, expires_at) return True - + def delete(self, key: str, session_id: Optional[str] = None) -> bool: sid, k = self._make_key(key, session_id) - + if sid in self._data and k in self._data[sid]: del self._data[sid][k] return True return False - + def exists(self, key: str, session_id: Optional[str] = None) -> bool: return self.get(key, session_id) is not None - + def clear_session(self, session_id: str) -> bool: if session_id in self._data: del self._data[session_id] if session_id in self._messages: del self._messages[session_id] return True - + def add_message( self, session_id: str, @@ -85,28 +84,28 @@ def add_message( content: str, metadata: Optional[Dict] = None, ) -> bool: - msg = { + msg: Dict[str, Any] = { "role": role, "content": content, "timestamp": datetime.utcnow().isoformat() + "Z", } if metadata: msg["metadata"] = metadata - + self._messages[session_id].append(msg) return True - + def get_messages( self, session_id: str, limit: Optional[int] = None, ) -> List[Dict]: messages = self._messages.get(session_id, []) - + if limit: return messages[-limit:] return messages.copy() - + def close(self) -> None: self._data.clear() self._messages.clear() diff --git a/ksadk/memory/backends/redis.py b/ksadk/memory/backends/redis.py index 8d6a7b0b..0dc42776 100644 --- a/ksadk/memory/backends/redis.py +++ b/ksadk/memory/backends/redis.py @@ -2,20 +2,20 @@ import json from datetime import datetime, timedelta -from typing import Any, Optional, List, Dict +from typing import Any, Dict, List, Optional from ksadk.memory.backends.base import BaseMemoryBackend class RedisBackend(BaseMemoryBackend): """Redis 存储后端 - + 适用于短期记忆和会话状态存储。 - + 使用示例: backend = RedisBackend(url="redis://localhost:6379/0") """ - + def __init__( self, url: str = "redis://localhost:6379/0", @@ -26,39 +26,40 @@ def __init__( self.prefix = prefix self.default_ttl = default_ttl self._client = None - + @property def client(self): """懒加载 Redis 客户端""" if self._client is None: try: import redis + self._client = redis.from_url(self.url, decode_responses=True) except ImportError: raise ImportError("redis package required. Install with: pip install redis") return self._client - + def _make_key(self, key: str, session_id: Optional[str]) -> str: """生成 Redis 键""" if session_id: return f"{self.prefix}session:{session_id}:{key}" return f"{self.prefix}global:{key}" - + def _messages_key(self, session_id: str) -> str: return f"{self.prefix}messages:{session_id}" - + def get(self, key: str, session_id: Optional[str] = None) -> Optional[Any]: redis_key = self._make_key(key, session_id) value = self.client.get(redis_key) - + if value is None: return None - + try: return json.loads(value) except json.JSONDecodeError: return value - + def set( self, key: str, @@ -67,40 +68,40 @@ def set( ttl: Optional[timedelta] = None, ) -> bool: redis_key = self._make_key(key, session_id) - + # 序列化 if not isinstance(value, str): value = json.dumps(value, ensure_ascii=False) - + # 设置过期时间 ex = None if ttl: ex = int(ttl.total_seconds()) elif self.default_ttl: ex = int(self.default_ttl.total_seconds()) - + self.client.set(redis_key, value, ex=ex) return True - + def delete(self, key: str, session_id: Optional[str] = None) -> bool: redis_key = self._make_key(key, session_id) - return self.client.delete(redis_key) > 0 - + return bool(self.client.delete(redis_key) > 0) + def exists(self, key: str, session_id: Optional[str] = None) -> bool: redis_key = self._make_key(key, session_id) - return self.client.exists(redis_key) > 0 - + return bool(self.client.exists(redis_key) > 0) + def clear_session(self, session_id: str) -> bool: pattern = f"{self.prefix}session:{session_id}:*" keys = list(self.client.scan_iter(match=pattern)) - + # 也删除消息 keys.append(self._messages_key(session_id)) - + if keys: self.client.delete(*keys) return True - + def add_message( self, session_id: str, @@ -108,38 +109,38 @@ def add_message( content: str, metadata: Optional[Dict] = None, ) -> bool: - msg = { + msg: dict[str, Any] = { "role": role, "content": content, "timestamp": datetime.utcnow().isoformat() + "Z", } if metadata: msg["metadata"] = metadata - + redis_key = self._messages_key(session_id) self.client.rpush(redis_key, json.dumps(msg, ensure_ascii=False)) - + # 可以设置消息历史的过期时间 if self.default_ttl: self.client.expire(redis_key, int(self.default_ttl.total_seconds())) - + return True - + def get_messages( self, session_id: str, limit: Optional[int] = None, ) -> List[Dict]: redis_key = self._messages_key(session_id) - + if limit: # 获取最后 N 条 raw = self.client.lrange(redis_key, -limit, -1) else: raw = self.client.lrange(redis_key, 0, -1) - + return [json.loads(m) for m in raw] - + def close(self) -> None: if self._client: self._client.close() diff --git a/ksadk/memory/langchain_tool.py b/ksadk/memory/langchain_tool.py index ece3de04..cd92313a 100644 --- a/ksadk/memory/langchain_tool.py +++ b/ksadk/memory/langchain_tool.py @@ -31,7 +31,8 @@ def save_memory_tool(content: str) -> dict: return load_memory_tool, save_memory_tool except ImportError: logger.warning( - "langchain-core not installed, returning raw functions. Install with: pip install langchain-core" + "langchain-core not installed, returning raw functions. " + "Install with: pip install langchain-core" ) return _load_memory, _save_memory diff --git a/ksadk/memory/manager.py b/ksadk/memory/manager.py index c5e2f457..efd0e9f0 100644 --- a/ksadk/memory/manager.py +++ b/ksadk/memory/manager.py @@ -1,9 +1,9 @@ """Memory Manager - 统一记忆管理接口""" -import os import logging +import os from datetime import timedelta -from typing import Any, Optional, List, Dict, Type +from typing import Any, Dict, List, Optional, Type, cast from ksadk.memory.backends.base import BaseMemoryBackend from ksadk.memory.backends.memory import InMemoryBackend @@ -24,26 +24,26 @@ def register_backend(name: str, backend_class: Type[BaseMemoryBackend]): class MemoryManager: """统一记忆管理器 - + 支持可插拔后端,自动从环境变量读取配置。 - + 使用示例: # 从环境变量自动配置 memory = MemoryManager.from_env() - + # 手动配置 memory = MemoryManager(backend="redis", url="redis://localhost:6379") - + # 存取数据 memory.set("key", {"data": "value"}, session_id="sess-123") data = memory.get("key", session_id="sess-123") - + # 消息历史 memory.add_message("sess-123", "user", "你好") memory.add_message("sess-123", "assistant", "你好!有什么可以帮助你的?") messages = memory.get_messages("sess-123") """ - + def __init__( self, backend: str = "memory", @@ -53,7 +53,7 @@ def __init__( **kwargs, ): """初始化 Memory Manager - + Args: backend: 后端类型 ("memory", "redis") url: 后端连接 URL @@ -63,7 +63,7 @@ def __init__( """ self.backend_name = backend self._backend = self._create_backend(backend, url, prefix, default_ttl, **kwargs) - + def _create_backend( self, backend: str, @@ -76,25 +76,29 @@ def _create_backend( # 延迟导入 Redis backend if backend == "redis": from ksadk.memory.backends.redis import RedisBackend + _BACKENDS["redis"] = RedisBackend - + if backend not in _BACKENDS: raise ValueError(f"Unknown backend: {backend}. Available: {list(_BACKENDS.keys())}") - - backend_class = _BACKENDS[backend] - + + backend_class: Any = _BACKENDS[backend] + # 根据后端类型传递参数 if backend == "memory": - return backend_class() + return cast(BaseMemoryBackend, backend_class()) elif backend == "redis": - return backend_class(url=url, prefix=prefix, default_ttl=default_ttl, **kwargs) + return cast( + BaseMemoryBackend, + backend_class(url=url, prefix=prefix, default_ttl=default_ttl, **kwargs), + ) else: - return backend_class(**kwargs) - + return cast(BaseMemoryBackend, backend_class(**kwargs)) + @classmethod def from_env(cls) -> "MemoryManager": """从环境变量创建 MemoryManager - + 环境变量: KSADK_MEMORY_BACKEND: 后端类型 (默认 "memory") KSADK_MEMORY_URL: 连接 URL (如 redis://localhost:6379) @@ -104,27 +108,27 @@ def from_env(cls) -> "MemoryManager": backend = os.environ.get("KSADK_MEMORY_BACKEND", "memory") url = os.environ.get("KSADK_MEMORY_URL", "") prefix = os.environ.get("KSADK_MEMORY_PREFIX", "ksadk:memory:") - + ttl = None ttl_str = os.environ.get("KSADK_MEMORY_TTL", "") if ttl_str: ttl = timedelta(seconds=int(ttl_str)) - + logger.debug(f"MemoryManager.from_env: backend={backend}, url={url[:20]}...") - + return cls( backend=backend, url=url or None, prefix=prefix, default_ttl=ttl, ) - + # ===== 键值操作 ===== - + def get(self, key: str, session_id: Optional[str] = None) -> Optional[Any]: """获取值""" return self._backend.get(key, session_id) - + def set( self, key: str, @@ -133,24 +137,24 @@ def set( ttl: Optional[timedelta] = None, ) -> bool: """设置值""" - return self._backend.set(key, value, session_id, ttl) - + return bool(self._backend.set(key, value, session_id, ttl)) + def delete(self, key: str, session_id: Optional[str] = None) -> bool: """删除键""" - return self._backend.delete(key, session_id) - + return bool(self._backend.delete(key, session_id)) + def exists(self, key: str, session_id: Optional[str] = None) -> bool: """检查键是否存在""" - return self._backend.exists(key, session_id) - + return bool(self._backend.exists(key, session_id)) + # ===== 会话操作 ===== - + def clear_session(self, session_id: str) -> bool: """清除会话所有数据""" - return self._backend.clear_session(session_id) - + return bool(self._backend.clear_session(session_id)) + # ===== 消息历史 ===== - + def add_message( self, session_id: str, @@ -159,39 +163,40 @@ def add_message( metadata: Optional[Dict] = None, ) -> bool: """添加消息""" - return self._backend.add_message(session_id, role, content, metadata) - + return bool(self._backend.add_message(session_id, role, content, metadata)) + def get_messages( self, session_id: str, limit: Optional[int] = None, ) -> List[Dict]: """获取消息历史""" - return self._backend.get_messages(session_id, limit) - + messages = self._backend.get_messages(session_id, limit) + return list(messages) + # ===== 便捷方法 ===== - + def get_state(self, session_id: str) -> Dict: """获取会话状态 (短期记忆)""" return self.get("__state__", session_id) or {} - + def set_state(self, session_id: str, state: Dict, ttl: Optional[timedelta] = None) -> bool: """设置会话状态""" return self.set("__state__", state, session_id, ttl) - + def update_state(self, session_id: str, updates: Dict) -> bool: """更新会话状态 (合并)""" current = self.get_state(session_id) current.update(updates) return self.set_state(session_id, current) - + def close(self) -> None: """关闭连接""" self._backend.close() - + def __enter__(self): return self - + def __exit__(self, exc_type, exc_val, exc_tb): self.close() @@ -202,10 +207,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): def get_memory_manager() -> MemoryManager: """获取全局 MemoryManager 单例 - + 在 Agent 代码中使用: from ksadk.memory import get_memory_manager - + memory = get_memory_manager() memory.set("key", "value") """ diff --git a/ksadk/memory/service.py b/ksadk/memory/service.py index 5f31efaf..984e1001 100644 --- a/ksadk/memory/service.py +++ b/ksadk/memory/service.py @@ -5,7 +5,7 @@ import json import logging import os -from typing import Any +from typing import Any, cast from ksadk.common.aicp_env import resolve_aicp_connection from ksadk.memory.adk.backends.base_ltm_backend import BaseLongTermMemoryBackend @@ -49,7 +49,9 @@ def __init__( self.backend_config = dict(backend_config or {}) self.top_k = top_k self.app_name = app_name - backend_index = getattr(backend, "index", "") if isinstance(backend, BaseLongTermMemoryBackend) else "" + backend_index = ( + getattr(backend, "index", "") if isinstance(backend, BaseLongTermMemoryBackend) else "" + ) self.index = index or backend_index or app_name or "default_app" self._backend = self._resolve_backend() @@ -75,12 +77,10 @@ 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", "") ), "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", "") ), "region": connection["region"], "endpoint": connection["endpoint"], @@ -109,7 +109,7 @@ def _resolve_backend(self) -> BaseLongTermMemoryBackend: backend_cls = get_long_term_memory_backend_cls(str(self.backend)) config = dict(self.backend_config) config.setdefault("index", self.index) - return backend_cls(**config) + return cast(BaseLongTermMemoryBackend, backend_cls(**config)) def search_entries(self, *, user_id: str, query: str, top_k: int | None = None) -> list[str]: return self._backend.search_memory( @@ -142,7 +142,9 @@ def save_event_strings( ) ) - def save_text(self, *, user_id: str, content: str, metadata: dict[str, Any] | None = None) -> bool: + def save_text( + self, *, user_id: str, content: str, metadata: dict[str, Any] | None = None + ) -> bool: payload = { "role": "user", "parts": [{"text": content}], diff --git a/ksadk/model_policy.py b/ksadk/model_policy.py index 526bf470..36c57fc0 100644 --- a/ksadk/model_policy.py +++ b/ksadk/model_policy.py @@ -58,10 +58,7 @@ def _deep_merge(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: merged = copy.deepcopy(base) for key, value in override.items(): - if ( - isinstance(value, Mapping) - and isinstance(merged.get(key), dict) - ): + if isinstance(value, Mapping) and isinstance(merged.get(key), dict): merged[key] = _deep_merge(merged[key], value) else: merged[key] = copy.deepcopy(value) @@ -196,7 +193,12 @@ def build_runtime_model_policy_env( multimodal = _role_model(normalized, "multimodal") has_primary = any( str(env.get(key) or "").strip() - for key in ("OPENCLAW_DEFAULT_MODEL", "HERMES_DEFAULT_MODEL", "OPENAI_MODEL_NAME", "MODEL_NAME") + for key in ( + "OPENCLAW_DEFAULT_MODEL", + "HERMES_DEFAULT_MODEL", + "OPENAI_MODEL_NAME", + "MODEL_NAME", + ) ) runtime_name = str(runtime or "").strip().lower() if runtime_name == "openclaw": @@ -206,7 +208,10 @@ def build_runtime_model_policy_env( env.setdefault("OPENCLAW_FALLBACK_MODEL", _provider_ref(fallback)) if multimodal: env.setdefault("OPENCLAW_IMAGE_MODEL", _provider_ref(multimodal)) - env.setdefault("OPENCLAW_MODEL_CATALOG_JSON", json.dumps(_catalog_from_policy(normalized), ensure_ascii=False)) + env.setdefault( + "OPENCLAW_MODEL_CATALOG_JSON", + json.dumps(_catalog_from_policy(normalized), ensure_ascii=False), + ) return env if runtime_name == "hermes": if primary and not has_primary: @@ -214,7 +219,10 @@ def build_runtime_model_policy_env( env["HERMES_DEFAULT_MODEL"] = primary if fallback: env.setdefault("HERMES_FALLBACK_MODEL", fallback) - env.setdefault("HERMES_MODEL_CATALOG_JSON", json.dumps(_catalog_from_policy(normalized), ensure_ascii=False)) + env.setdefault( + "HERMES_MODEL_CATALOG_JSON", + json.dumps(_catalog_from_policy(normalized), ensure_ascii=False), + ) return env if primary and not has_primary: env["OPENAI_MODEL_NAME"] = primary diff --git a/ksadk/model_proxy/__init__.py b/ksadk/model_proxy/__init__.py new file mode 100644 index 00000000..e6fef8f4 --- /dev/null +++ b/ksadk/model_proxy/__init__.py @@ -0,0 +1,51 @@ +"""ksadk 模型出口协议转换层(P1 草案,待 codex review). + +把 OpenAI Responses API <-> Chat Completions API 的转换内化为 ksadk 的模型出口中间层, +让只发 responses 的 runtime(codex)能透明使用所有 chat 协议模型,用户零感知。 + +设计见 docs/responses-chat-protocol-proxy-plan.md。 + +模块边界: +- transform: 纯转换器(无环境依赖),请求/响应/流式状态机。 +- config: ProxyConfig 显式注入,不在导入时读 env。 +- server: create_app(config) app factory + ProxyServer 生命周期管理。 +- protocol_proxy: 可独立运行的入口(读 env → config → uvicorn)。 +""" + +from .bootstrap import setup_proxy_redirect_if_enabled, teardown_proxy_redirect +from .cache import CapabilityCache +from .config import ProxyConfig +from .detect import ModelCapabilities, probe_responses_capability +from .gate import ProxyGate +from .namespace import build_restore_map, flatten_namespace_tool_name, flatten_request_namespaces +from .server import ProxyServer # noqa: F401 (re-export) +from .transform import ( + Streamer, + chat_to_response, + convert_tool_choice, + convert_tools, + convert_usage, + input_to_messages, + responses_to_chat, +) + +__all__ = [ + "CapabilityCache", + "ModelCapabilities", + "ProxyConfig", + "ProxyGate", + "ProxyServer", + "Streamer", + "build_restore_map", + "chat_to_response", + "convert_tool_choice", + "convert_tools", + "convert_usage", + "flatten_namespace_tool_name", + "flatten_request_namespaces", + "input_to_messages", + "probe_responses_capability", + "responses_to_chat", + "setup_proxy_redirect_if_enabled", + "teardown_proxy_redirect", +] diff --git a/ksadk/model_proxy/bootstrap.py b/ksadk/model_proxy/bootstrap.py new file mode 100644 index 00000000..42695c36 --- /dev/null +++ b/ksadk/model_proxy/bootstrap.py @@ -0,0 +1,80 @@ +"""env 型框架接线(v2.2):按需懒起 ProxyServer 并重定向 OPENAI_BASE_URL。 + +review v1 约束:setup_environment 在框架检测之前执行,无法判断 runtime。 +故本模块用 :class:`ProxyGate`(模型白名单,不依赖 runtime 判断)控制: +gate.is_on(model) 为真时起一个进程内 ProxyServer,把 OPENAI_BASE_URL/ +OPENAI_API_BASE 重定向到它;env 框架(ADK/langchain/deepagents 发 chat)经代理 +的 /v1/chat/completions 直通上游(字节级,缓存不受影响),codex 走 v2.1 不经此。 + +默认 gate 关(ProxyGate.enabled=False),不重定向,历史路径零影响。 +""" + +from __future__ import annotations + +import os +from typing import Optional + +from .config import ProxyConfig +from .gate import ProxyGate +from .server import ProxyServer + +# 进程级单例:setup_environment 多次调用只起一个 proxy +_proxy: Optional[ProxyServer] = None +_original_base: Optional[str] = None + + +def setup_proxy_redirect_if_enabled( + gate: ProxyGate | None = None, + model: str | None = None, + upstream_base: str | None = None, + api_key: str | None = None, + local_token: str | None = None, +) -> str | None: + """gate 开启且模型在白名单时起 ProxyServer 并重定向 OPENAI_BASE_URL。 + + 返回 proxy base_url(已重定向)或 None(未启用)。多次调用幂等(单例)。 + 上游凭证来自 ProxyConfig(从 env 读),不下发给子进程(凭证闭合)。 + """ + global _proxy, _original_base + if _proxy is not None: + return _proxy.base_url # 幂等:已起 + gate = gate or ProxyGate.from_env() + model = model or os.environ.get("OPENAI_MODEL_NAME") or os.environ.get("MODEL_NAME") or "" + if not gate.is_on(model=model or None): + return None + upstream = ( + upstream_base + or os.environ.get("OPENAI_BASE_URL") + or os.environ.get("OPENAI_API_BASE") + or "" + ) + key = ( + api_key or os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY") or "" + ) + token = local_token or os.environ.get("KSADK_PROXY_TOKEN") or "" + if not upstream or not key: + return None # 缺凭证/上游,不启用(保持原 env) + cfg = ProxyConfig(upstream_base=upstream, api_key=key, local_token=token) + srv = ProxyServer(cfg) + srv.start() + _proxy = srv + # 记录原 base 并重定向(双别名都指向 proxy) + _original_base = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE") + os.environ["OPENAI_BASE_URL"] = srv.base_url + os.environ["OPENAI_API_BASE"] = srv.base_url + if not token: + # 非 codex 路径(env 框架)无 token:代理 local_token 为空,仅回环监听才允许 + os.environ["KSADK_PROXY_TOKEN"] = "" + return srv.base_url + + +def teardown_proxy_redirect() -> None: + """回收 proxy 并恢复原 OPENAI_BASE_URL(进程退出/卸载时调)。""" + global _proxy, _original_base + if _proxy is not None: + _proxy.stop() + _proxy = None + if _original_base is not None: + os.environ["OPENAI_BASE_URL"] = _original_base + os.environ["OPENAI_API_BASE"] = _original_base + _original_base = None diff --git a/ksadk/model_proxy/cache.py b/ksadk/model_proxy/cache.py new file mode 100644 index 00000000..969ef7f6 --- /dev/null +++ b/ksadk/model_proxy/cache.py @@ -0,0 +1,165 @@ +"""能力探测缓存(v2.4):租户隔离键 + TTL + singleflight + 主动失效。 + +review v1 指出 ``(model, base_url)`` 缺 credential/tenant 维度:同一网关不同 key +能力可能不同(如星流 glm-5.1 的 responses 403 按 consumer 鉴权),404 也可能是 +"模型不存在"而非"endpoint 不存在"。本模块: + +- 键 = ``(model, base_url, credential_scope)``;credential_scope 用进程内 HMAC + 标识符,避免明文 key 入键/日志,又能区分不同 key。 +- **明确判定**(supported/unsupported)长缓存(TTL);**不确定**(unknown)不缓存, + 下次重探——避免把一次抖动固化成长期误判。 +- **singleflight**:并发同键只探一次,其余等结果(用 per-key Future)。 +- **主动失效**:`invalidate(model, base_url, scope)` 与 `clear()` 入口。 +- 撞名/模型不存在的结构化区分由 detect.py 的错误分类负责,本缓存只存结论。 +""" + +from __future__ import annotations + +import asyncio +import hashlib +import secrets +import threading +import time +from dataclasses import dataclass +from typing import Any, Callable + +from .detect import ModelCapabilities + +# The capability cache is process-local, so its credential namespace need not +# survive a restart. A random salt prevents cache identifiers from being used +# as an offline oracle for API keys and avoids retaining the original credential. +_SCOPE_SALT = secrets.token_bytes(16) +_SCOPE_ITERATIONS = 100_000 + + +def credential_scope(credential: str) -> str: + """Return a process-local, non-secret cache scope for a credential.""" + return hashlib.pbkdf2_hmac( + "sha256", credential.encode("utf-8"), _SCOPE_SALT, _SCOPE_ITERATIONS, dklen=16 + ).hex() + + +@dataclass +class _Entry: + caps: ModelCapabilities + expires_at: float # 0 = 永不过期(明确判定) + + +class CapabilityCache: + """能力矩阵缓存:线程安全 + asyncio 安全(单进程内)。""" + + def __init__(self, ttl: float = 3600.0): + self._ttl = ttl + self._lock = threading.Lock() + self._entries: dict[tuple[str, str, str], _Entry] = {} + # singleflight:sync per-key Event,async per-key Future + self._inflight_sync: dict[tuple[str, str, str], threading.Event] = {} + self._inflight_async: dict[tuple[str, str, str], asyncio.Future] = {} + + def _key(self, model: str, base: str, scope: str) -> tuple[str, str, str]: + return (model, base.rstrip("/"), scope) + + def get(self, model: str, base: str, scope: str) -> ModelCapabilities | None: + """命中且未过期返回 caps;过期/不存在/不确定(不缓存)返回 None。""" + k = self._key(model, base, scope) + with self._lock: + e = self._entries.get(k) + if e is None: + return None + if e.expires_at and time.time() > e.expires_at: + self._entries.pop(k, None) + return None + return e.caps + + def put(self, model: str, base: str, scope: str, caps: ModelCapabilities) -> None: + """只缓存明确判定(supported/unsupported);unknown 不缓存。""" + if caps.verdict == "unknown": + return + k = self._key(model, base, scope) + expires = time.time() + self._ttl if self._ttl > 0 else 0 + with self._lock: + self._entries[k] = _Entry(caps=caps, expires_at=expires) + + def invalidate(self, model: str, base: str, scope: str) -> None: + k = self._key(model, base, scope) + with self._lock: + self._entries.pop(k, None) + + def clear(self) -> None: + with self._lock: + self._entries.clear() + + # ---- singleflight:并发同键只探一次 ---- + def get_or_probe( + self, + model: str, + base: str, + scope: str, + probe: Callable[[str, str], ModelCapabilities], + ) -> ModelCapabilities: + """命中直接返回;否则 singleflight 探测(并发同键只探一次),结果入缓存。 + + ``scope`` is a pre-derived credential namespace. ``probe`` is a sync + callable(model, base) -> ModelCapabilities and closes over credentials. + """ + cached = self.get(model, base, scope) + if cached is not None: + return cached + k = self._key(model, base, scope) + with self._lock: + ev = self._inflight_sync.get(k) + if ev is not None: + # 已有并发探测在进行:等它完成,读缓存(探测方负责 put) + pass + else: + ev = threading.Event() + self._inflight_sync[k] = ev + ev = None # 标记本线程负责探测 + if ev is not None: + ev.wait(timeout=30.0) + return self.get(model, base, scope) or ModelCapabilities(verdict="unknown") + # 本线程负责探测 + try: + caps = probe(model, base) + self.put(model, base, scope, caps) + return caps + finally: + with self._lock: + ev_done = self._inflight_sync.pop(k, None) + if ev_done is not None: + ev_done.set() + + async def aget_or_probe( + self, + model: str, + base: str, + scope: str, + probe: Callable[[str, str], Any], + ) -> ModelCapabilities: + """async singleflight 版:probe 是 async callable -> ModelCapabilities。""" + cached = self.get(model, base, scope) + if cached is not None: + return cached + k = self._key(model, base, scope) + loop = asyncio.get_running_loop() + with self._lock: + fut = self._inflight_async.get(k) + if fut is None: + fut = loop.create_future() + self._inflight_async[k] = fut + own = True + else: + own = False + if not own: + await fut # type: ignore[no-any-return] + # owner 已 put 入缓存;从缓存读结果(不用 fut 的值,它是 None 占位) + return self.get(model, base, scope) or ModelCapabilities(verdict="unknown") + try: + caps: ModelCapabilities = await probe(model, base) + self.put(model, base, scope, caps) + return caps + finally: + with self._lock: + self._inflight_async.pop(k, None) + if not fut.done(): + fut.set_result(None) # 唤醒等待者(它们会读缓存) diff --git a/ksadk/model_proxy/config.py b/ksadk/model_proxy/config.py new file mode 100644 index 00000000..dd49b6e7 --- /dev/null +++ b/ksadk/model_proxy/config.py @@ -0,0 +1,45 @@ +"""ProxyConfig:转换 proxy 的配置(显式注入,不在模块导入时读 env,便于内化与测试)。""" + +import os +from dataclasses import dataclass +from urllib.parse import urlsplit + +# HTTPS(凭证安全):不再用明文 HTTP 携带 Bearer key。 +DEFAULT_UPSTREAM_BASE = "https://kspmas.ksyun.com/v1" + +_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} + + +@dataclass +class ProxyConfig: + upstream_base: str = DEFAULT_UPSTREAM_BASE + api_key: str = "" # 上游凭证(proxy -> 上游) + local_token: str = "" # 本地 proxy 鉴权 token(codex -> proxy);空则不校验(仅本地调试) + timeout: float = 180.0 + + def __post_init__(self) -> None: + self.upstream_base = _require_secure_upstream(self.upstream_base.rstrip("/")) + + @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", ""), + timeout=float(os.environ.get("UPSTREAM_TIMEOUT", "180")), + ) + + +def _require_secure_upstream(upstream: str) -> str: + """凭证安全:scheme 仅 http/https;非回环主机必须 https(避免明文携带 Bearer)。 + + 用 urlsplit 规范化 scheme/hostname,堵 `HTTP://`(大小写)与 `ftp://` 绕过。 + """ + parts = urlsplit(upstream) + scheme = parts.scheme.lower() + host = (parts.hostname or "").lower() + if scheme not in ("http", "https"): + raise ValueError(f"不支持的上游 scheme(仅 http/https): {upstream}") + if scheme == "http" and host not in _LOOPBACK_HOSTS: + raise ValueError(f"上游必须使用 https(携带凭证,禁明文 http): {upstream}") + return upstream diff --git a/ksadk/model_proxy/demo_webui.py b/ksadk/model_proxy/demo_webui.py new file mode 100644 index 00000000..0eedfae7 --- /dev/null +++ b/ksadk/model_proxy/demo_webui.py @@ -0,0 +1,115 @@ +"""极简 codex 对话 webui(端到端验证用,非生产入口)。 + +绕过 ksadk web 的框架检测(它不认 codex):直接用 AsyncCodexClient + 一个 +最小 FastAPI 提供 HTML 页 + /chat SSE,在浏览器里展示 codex runtime 经 +model_proxy 代理跑星流 chat 模型的真实对话流。 + +用法: + KSADK_CODEX_USE_PROXY=1 OPENAI_API_BASE=https://kspmas.ksyun.com/v1 \ + OPENAI_API_KEY=xxx CODEX_HOME=/tmp/codex_clean_runtime \ + uv run python -m ksadk.model_proxy.demo_webui --port 8877 +""" + +from __future__ import annotations + +import json +import logging +import os + +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, StreamingResponse + +logger = logging.getLogger(__name__) + +# ruff: noqa: E501 内联 HTML/CSS/JS 演示字面量,长行不拆 + +_PAGE = """ +codex runtime e2e + +

codex runtime → model_proxy → 星流 chat

+
+
+
+""" + + +def create_demo_app() -> FastAPI: + from ksadk.codex.client import AsyncCodexClient + + app = FastAPI() + client: dict = {"c": None, "tid": None} + + @app.get("/", response_class=HTMLResponse) + def index(): + return _PAGE + + @app.post("/chat") + async def chat(req: Request): + body = await req.json() + q = body.get("q", "") + + async def gen(): + if client["c"] is None: + c = AsyncCodexClient() + client["c"] = c + client["tid"] = await c.start_thread( + config={ + "model": os.environ.get("OPENAI_MODEL_NAME", "glm-5.2"), + "sandbox_read_only": True, + } + ) + try: + async for ev in client["c"].run_turn(client["tid"], q): + m = ev.get("method", "") + if m == "item/agentMessage/delta": + yield f"data: {json.dumps({'delta': ev['params'].get('delta', '')}, ensure_ascii=False)}\n\n" + elif m == "item/completed": + it = ev["params"]["item"] + if it.get("type") == "agentMessage" and it.get("text"): + yield f"data: {json.dumps({'reply': it['text']}, ensure_ascii=False)}\n\n" + elif m == "error": + logger.warning("Codex demo runtime reported an error") + yield 'data: {"error":"Codex runtime failed; see local logs."}\n\n' + except Exception as e: # noqa: BLE001 + logger.warning("Codex demo runtime failed", exc_info=e) + yield 'data: {"error":"Codex runtime failed; see local logs."}\n\n' + + return StreamingResponse(gen(), media_type="text/event-stream") + + @app.on_event("shutdown") + async def shutdown(): + if client["c"] is not None: + await client["c"].close() + + return app + + +def main(): + import argparse + + p = argparse.ArgumentParser() + p.add_argument("--port", type=int, default=8877) + args = p.parse_args() + uvicorn.run(create_demo_app(), host="127.0.0.1", port=args.port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/ksadk/model_proxy/detect.py b/ksadk/model_proxy/detect.py new file mode 100644 index 00000000..2a3481f2 --- /dev/null +++ b/ksadk/model_proxy/detect.py @@ -0,0 +1,145 @@ +"""模型能力矩阵探测(v2.3):替代简单 responses/chat 二分。 + +"支持 responses" 不等于 "该直通"——glm-5.1 原生 responses 丢增量 delta + 拒 namespace 工具, +经转换层反而更好。本模块用能力矩阵描述一个 (model, base_url, credential) 的真实能力, +供路由决策(直通 responses / 走转换层 chat / …)。 + +探测铁律(review v1): +- **功能性 probe 而非状态码 probe**:发最小真请求打 /v1/responses,校验返回是否为 + 合法 responses 结构(有 ``output``/``status``),不只看 HTTP 200——网关对不支持的 + 端点有时也返回 200 但内容是错误页。 +- **只在 "确凿协议否定" 时翻案**:`404/405/400 unknown endpoint` 才判 "不支持 responses"; + 超时/5xx/429/401/403 一律 "unknown",不改变判定(故障 ≠ 能力缺失)。 +- ``stream_delta_ok`` 需真发流式请求才能判定,成本高,本模块默认 None(不探), + 由调用方按需触发或默认走转换层(转换层自己生成完整 delta)。 +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any, Literal + +import httpx + +Verdict = Literal["supported", "unsupported", "unknown"] + + +@dataclass +class ModelCapabilities: + """一个 (model, base_url, credential_scope) 的协议能力矩阵。""" + + responses_supported: bool | None = None # None = 未知(未探/不确定) + stream_delta_ok: bool | None = None # 流式增量 delta;None = 未探 + tool_types: set[str] = field(default_factory=set) # 支持的工具 namespace + preferred_protocol: str = "chat" # "responses" | "chat":路由该走哪条 + checked_at: float = 0.0 + verdict: Verdict = "unknown" # 最近一次探测结论 + + +def _classify_responses_error(status: int, body: str) -> Verdict: + """把非 200 响应分类成 supported/unsupported/unknown。 + + 只有 "确凿协议否定" 才判 unsupported;故障类一律 unknown(不误固化)。 + """ + if status in (404, 405): + return "unsupported" + if status == 400: + low = (body or "").lower() + # 400 且 body 含 "unknown/not supported/endpoint" -> endpoint 不支持 + if any(k in low for k in ("unknown", "not supported", "endpoint", "unrecognized")): + return "unsupported" + return "unknown" # 400 但不像协议级否定(可能是请求字段问题) + # 401/403:鉴权/权限问题,不代表 endpoint 不存在(同 key 不同能力,见 glm-5.1 403) + if status in (401, 403): + return "unknown" + return "unknown" # 5xx/429/其他:故障类 + + +def probe_responses_capability( + client: httpx.Client | httpx.AsyncClient, + base: str, + key: str, + model: str, + timeout: float = 30.0, +): + """功能性 probe /v1/responses,返回能力矩阵(sync) 或 coroutine(async)。 + + - 200 且结构合法(output+status)→ supported,preferred_protocol=responses + - 200 但非 responses 结构(网关伪 200)→ unsupported,preferred_protocol=chat + - 404/405/400 unknown → unsupported,preferred_protocol=chat + - 超时/5xx/429/401/403 → unknown,preferred_protocol 保持默认 chat(保守走转换层) + + 按 client 类型分发:``httpx.Client`` 同步返回 ``ModelCapabilities``; + ``httpx.AsyncClient`` 返回 coroutine(调用方 await)。 + """ + if isinstance(client, httpx.AsyncClient): + return _probe_async(client, base, key, model, timeout) + if isinstance(client, httpx.Client): + return _probe_sync(client, base, key, model, timeout) + raise TypeError(f"client 必须是 httpx.Client/AsyncClient,不是 {type(client).__name__}") + + +def _probe_sync( + client: httpx.Client, base: str, key: str, model: str, timeout: float +) -> ModelCapabilities: + caps = ModelCapabilities(checked_at=time.time(), verdict="unknown") + url = f"{base.rstrip('/')}/responses" + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + payload = {"model": model, "input": "hi", "max_output_tokens": 1, "stream": False} + try: + r = client.post(url, json=payload, headers=headers, timeout=timeout) + except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError): + return _finalize(caps) + try: + data = r.json() + except ValueError: + data = None + return _apply_response(caps, r.status_code, r.text, data) + + +async def _probe_async( + client: httpx.AsyncClient, base: str, key: str, model: str, timeout: float +) -> ModelCapabilities: + caps = ModelCapabilities(checked_at=time.time(), verdict="unknown") + url = f"{base.rstrip('/')}/responses" + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + payload = {"model": model, "input": "hi", "max_output_tokens": 1, "stream": False} + try: + r = await client.post(url, json=payload, headers=headers, timeout=timeout) + except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError): + return _finalize(caps) + try: + data = r.json() + except ValueError: + data = None + return _apply_response(caps, r.status_code, r.text, data) + + +def _apply_response( + caps: ModelCapabilities, status: int, text: str, data: Any +) -> ModelCapabilities: + if status == 200: + # 功能性校验:合法 responses 结构有 output + status(不只看 200) + if isinstance(data, dict) and "output" in data and "status" in data: + caps.responses_supported = True + caps.verdict = "supported" + else: + # 200 但不是 responses 结构(网关伪 200 / 错误页) + caps.responses_supported = False + caps.verdict = "unsupported" + return _finalize(caps) + caps.verdict = _classify_responses_error(status, text) + caps.responses_supported = False if caps.verdict == "unsupported" else None + return _finalize(caps) + + +def _finalize(caps: ModelCapabilities) -> ModelCapabilities: + """根据 verdict 定 preferred_protocol(保守:不确定也走转换层 chat)。""" + if caps.verdict == "supported" and caps.responses_supported: + caps.preferred_protocol = "responses" + else: + # unsupported 或 unknown 都默认 chat(走转换层):unknown 时走转换层更安全, + # 因为转换层对 chat 模型是确定可用的,而直连 responses 在 unknown 下有风险 + caps.preferred_protocol = "chat" + return caps diff --git a/ksadk/model_proxy/gate.py b/ksadk/model_proxy/gate.py new file mode 100644 index 00000000..753457e0 --- /dev/null +++ b/ksadk/model_proxy/gate.py @@ -0,0 +1,68 @@ +"""开关与灰度配置(v2.5):默认关闭,按需启用,一键回退直连。 + +review v1 要求:全局默认关,支持按 agent/按模型白名单开启,故障一键回退。 +设计为纯配置判定(env/agentengine.yaml 解析后传入),不含网络/proxy 生命周期—— +后者由 v2.1(codex)/v2.2(env 框架)各自接线时调用。 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass +class ProxyGate: + """协议转换层的开关/灰度判定(纯函数,不持有运行时状态)。 + + - ``enabled``:全局开关,默认 False。 + - ``agent_allowlist``/``model_allowlist``:细粒度白名单;空=不按此维度放行。 + - ``denylist``:显式拒绝(优先级最高),用于故障一键回退某 agent/模型。 + + 判定优先级:denylist > 全局 enabled > 白名单。 + """ + + enabled: bool = False + agent_allowlist: set[str] = field(default_factory=set) + model_allowlist: set[str] = field(default_factory=set) + denylist: set[str] = field(default_factory=set) + + def is_on(self, agent: str | None = None, model: str | None = None) -> bool: + """是否对该 (agent, model) 启用转换层。 + + - denylist 命中(agent 或 model)→ False(一键回退) + - 全局 enabled=False → False(默认关) + - 全局 enabled=True → True(除非 denylist) + - 全局关但白名单命中(agent 或 model)→ True(灰度放行) + """ + if agent and agent in self.denylist: + return False + if model and model in self.denylist: + return False + if self.enabled: + return True + if agent and agent in self.agent_allowlist: + return True + if model and model in self.model_allowlist: + return True + return False + + @classmethod + def from_env(cls) -> "ProxyGate": + """从 env 读开关与白名单(逗号分隔)。 + + - ``KSADK_MODEL_PROXY_ENABLED=1`` 全局开 + - ``KSADK_MODEL_PROXY_AGENTS=a,b`` agent 白名单 + - ``KSADK_MODEL_PROXY_MODELS=glm-5.2`` model 白名单 + - ``KSADK_MODEL_PROXY_DENY=c`` 一键回退 + """ + def _split(name: str) -> set[str]: + v = os.environ.get(name, "").strip() + return {x.strip() for x in v.split(",") if x.strip()} if v else set() + + return cls( + enabled=os.environ.get("KSADK_MODEL_PROXY_ENABLED") == "1", + agent_allowlist=_split("KSADK_MODEL_PROXY_AGENTS"), + model_allowlist=_split("KSADK_MODEL_PROXY_MODELS"), + denylist=_split("KSADK_MODEL_PROXY_DENY"), + ) diff --git a/ksadk/model_proxy/namespace.py b/ksadk/model_proxy/namespace.py new file mode 100644 index 00000000..a629f338 --- /dev/null +++ b/ksadk/model_proxy/namespace.py @@ -0,0 +1,173 @@ +"""codex namespace 工具拍平 + 还原(responses↔chat 路径)。 + +codex 0.142+ 用 namespace 工具声明插件/MCP 工具: + + {"type":"namespace","name":"mcp__x__","tools":[{"type":"function","name":"foo",...}]} + +并在 input 历史里发 namespace-qualified function_call: + + {"type":"function_call","name":"foo","namespace":"mcp__x__",...} + +chat completions 只认顶层 ``function`` 工具(chat 嵌套)和 ``function.name``, +不认 ``namespace`` 字段。本模块把 namespace 工具**拍平**成顶层 function, +名字用确定性的 ``__``(超长 sha256 截断,与 cc-switch 一致), +响应侧再把 flat 名字**还原**回 ``{name, namespace}``,让 codex client 能匹配 +自己的 namespace 工具注册表。 + +请求侧拍平 + 响应侧还原都从**同一份 request tools** 经 +:func:`flatten_namespace_tool_name` 派生名字映射,前后一致,无需跨请求状态。 + +参照 cc-switch ``transform_codex_responses_namespace.rs``(用 Python 重写)。 +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +CHAT_TOOL_NAME_MAX_LEN = 64 + + +def flatten_namespace_tool_name(namespace: str, name: str) -> str: + """``__``;超长则尾部接 sha256 截断,保持在 64 字符内。 + + 截断按字符边界,保证返回值是合法 UTF-8(不会从多字节字符中间切断)。 + """ + full = f"{namespace}__{name}" + if len(full) <= CHAT_TOOL_NAME_MAX_LEN: + return full + suffix = "__" + hashlib.sha256(full.encode("utf-8")).hexdigest()[:8] + prefix_len = CHAT_TOOL_NAME_MAX_LEN - len(suffix) + prefix = "" + for ch in full: + if len(prefix.encode("utf-8")) + len(ch.encode("utf-8")) > prefix_len: + break + prefix += ch + return prefix + suffix + + +def _namespace_children(tool: dict) -> list[dict]: + for key in ("tools", "children"): + kids = tool.get(key) + if isinstance(kids, list): + return kids + return [] + + +def build_restore_map(tools: list[Any] | None) -> dict[str, dict[str, str]]: + """从 request 的 namespace 工具声明建 ``flat -> {namespace, name}`` 映射。 + + 两个不同 child 拍平后撞名时,后者覆盖前者(与 cc-switch ``or_insert`` 不同, + cc-switch 用 first-wins 并对撞名报错;这里取 first-wins + 调用方撞名检测)。 + """ + restore: dict[str, dict[str, str]] = {} + if not tools: + return restore + for tool in tools: + if not isinstance(tool, dict) or tool.get("type") != "namespace": + continue + namespace = (tool.get("name") or "").strip() + if not namespace: + continue + for child in _namespace_children(tool): + if not isinstance(child, dict) or child.get("type") != "function": + continue + name = (child.get("name") or "").strip() + if not name: + continue + flat = flatten_namespace_tool_name(namespace, name) + restore.setdefault(flat, {"namespace": namespace, "name": name}) + return restore + + +def _rewrite_qualified_calls(value: Any, owners: dict[str, dict[str, str]]) -> bool: + """递归把 input 里的 namespace-qualified function_call 重写成 flat name。 + + ``{type:function_call, name:child, namespace:ns}`` -> ``{type:function_call, name:flat}`` + (去掉 namespace 字段);只在 (ns, name) 与 owners 条目匹配时重写。 + """ + changed = False + if isinstance(value, list): + for item in value: + changed |= _rewrite_qualified_calls(item, owners) + elif isinstance(value, dict): + if value.get("type") == "function_call": + namespace = (value.get("namespace") or "").strip() + name = (value.get("name") or "").strip() + if namespace and name: + flat = flatten_namespace_tool_name(namespace, name) + entry = owners.get(flat) + if entry and entry["namespace"] == namespace and entry["name"] == name: + value["name"] = flat + value.pop("namespace", None) + changed = True + for child in value.values(): + changed |= _rewrite_qualified_calls(child, owners) + return changed + + +def flatten_request_namespaces(body: dict) -> dict[str, dict[str, str]]: + """原地拍平 body 的 namespace 工具,返回 restore_map(供响应侧还原)。 + + - tools:namespace children 提升为顶层 responses function 工具(flat name), + 原 namespace 工具移除;检测撞名(两个 child 拍平后同名)则 raise。 + - input:namespace-qualified function_call 重写为 flat name。 + - tool_choice:namespace-typed 丢弃(chat 不认)。 + + 返回 ``flat -> {namespace, name}`` 映射;无 namespace 工具时返回空 dict, + 调用方可据此跳过响应侧还原。 + """ + tools = body.get("tools") + if not isinstance(tools, list): + return {} + restore = build_restore_map(tools) + flat_tools: list[dict] = [] + seen_flat: set[str] = set() + for tool in tools: + if not isinstance(tool, dict): + continue + if tool.get("type") == "namespace": + namespace = (tool.get("name") or "").strip() + for child in _namespace_children(tool): + if not isinstance(child, dict) or child.get("type") != "function": + continue + name = (child.get("name") or "").strip() + if not name or not namespace: + continue + flat = flatten_namespace_tool_name(namespace, name) + if flat in seen_flat: + raise ValueError( + f"namespace 拍平撞名:{flat} 来自不同 child,上游无法消歧" + ) + seen_flat.add(flat) + flat_tools.append({ + "type": "function", + "name": flat, + "description": child.get("description", ""), + "parameters": child.get("parameters", {"type": "object", "properties": {}}), + **({"strict": child["strict"]} if "strict" in child else {}), + }) + else: + flat_tools.append(tool) + body["tools"] = flat_tools + if restore and isinstance(body.get("input"), list): + _rewrite_qualified_calls(body["input"], restore) + tc = body.get("tool_choice") + if isinstance(tc, dict) and tc.get("type") == "namespace": + body.pop("tool_choice", None) + return restore + + +def restore_function_call(item: dict, restore_map: dict[str, dict[str, str]]) -> dict: + """把响应里 function_call 的 flat name 还原成 ``{name, namespace}``(原地改)。 + + 无映射或 name 不在映射中时原样返回(普通 function 工具的 call 不受影响)。 + """ + if not restore_map or item.get("type") != "function_call": + return item + flat = item.get("name") + entry = restore_map.get(flat) if isinstance(flat, str) else None + if entry: + item["name"] = entry["name"] + item["namespace"] = entry["namespace"] + return item diff --git a/ksadk/model_proxy/protocol_proxy.py b/ksadk/model_proxy/protocol_proxy.py new file mode 100644 index 00000000..cb8235c9 --- /dev/null +++ b/ksadk/model_proxy/protocol_proxy.py @@ -0,0 +1,22 @@ +"""可独立运行的转换 proxy 入口:读 env → ProxyConfig → uvicorn。 + +用法: + KSPMAS_API_KEY=xxx python -m ksadk.model_proxy.protocol_proxy # 默认 127.0.0.1:8899 +""" + +import os + +import uvicorn + +from .config import ProxyConfig +from .server import create_app + + +def main(): + config = ProxyConfig.from_env() + port = int(os.environ.get("PORT", "8899")) + uvicorn.run(create_app(config), host="127.0.0.1", port=port, log_level="warning") + + +if __name__ == "__main__": + main() diff --git a/ksadk/model_proxy/server.py b/ksadk/model_proxy/server.py new file mode 100644 index 00000000..f2afe5c8 --- /dev/null +++ b/ksadk/model_proxy/server.py @@ -0,0 +1,300 @@ +"""app factory + 进程内转换 proxy 生命周期管理。 + +把 HTTP 层与配置/转换解耦:create_app(config) 返回 FastAPI app; +ProxyServer 在后台线程懒起一个 localhost uvicorn,幂等 + 加锁 + 干净回收(随 runner 退出)。 +""" + +import asyncio +import json +import logging +import socket +import threading +import time +import uuid + +import httpx +import uvicorn +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from .config import _LOOPBACK_HOSTS, ProxyConfig +from .transform import Streamer, UnsupportedToolsError, chat_to_response, responses_to_chat + +logger = logging.getLogger(__name__) + + +def _upstream_error(status_code: int) -> JSONResponse: + """Return a stable public error without relaying an upstream traceback.""" + return JSONResponse( + status_code=status_code, + content={ + "error": { + "type": "upstream_error", + "message": "The model upstream rejected the request.", + } + }, + ) + + +def create_app(config: ProxyConfig) -> FastAPI: + base = config.upstream_base.rstrip("/") + headers = {"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"} + app = FastAPI() + # 应用层取消信号:stop() 时置位,让活动 SSE 的 _stream_gen 主动 break(比单靠 force_exit 可靠) + cancel = threading.Event() + app.state.cancel_event = cancel + + @app.get("/healthz") + async def healthz(): + return {"ok": True, "upstream": base} + + @app.post("/v1/chat/completions") + async def chat_passthrough(req: Request): + """chat 直通端点(env 框架发 chat 经代理原样转发上游,不转换)。 + + 让代理成为统一模型出口:env 框架(ADK/langchain/deepagents 发 chat)经此直通, + codex(发 responses)经 /v1/responses 转换。统一出口为后续能力探测/路由打底。 + chat 直通字节级转发 + 流式透传,前缀缓存不受影响。 + """ + if ( + config.local_token + and req.headers.get("authorization", "") != f"Bearer {config.local_token}" + ): + return JSONResponse( + status_code=401, content={"error": {"type": "authentication_error"}} + ) + body = await req.json() + is_stream = bool(body.get("stream")) + up_headers = { + "Authorization": f"Bearer {config.api_key}", + "Content-Type": "application/json", + } + if is_stream: + return StreamingResponse( + _chat_passthrough_stream(config, base, up_headers, body), + 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=body, headers=up_headers) + if r.is_error: + logger.warning("chat upstream rejected request: status=%s", r.status_code) + return _upstream_error(r.status_code) + return JSONResponse(status_code=r.status_code, content=r.json() if r.content else None) + + @app.post("/v1/responses") + async def responses(req: Request): + # 本地鉴权:codex -> 本地 proxy 用 KSADK_PROXY_TOKEN;上游凭证独立保存于 config.api_key + if ( + config.local_token + and req.headers.get("authorization", "") != f"Bearer {config.local_token}" + ): + return JSONResponse( + status_code=401, + content={ + "error": {"type": "authentication_error", "message": "invalid proxy token"} + }, + ) + body = await req.json() + try: + chat_req, restore_map = responses_to_chat(body) + except UnsupportedToolsError as e: + logger.info("responses request uses unsupported tools: %s", e) + return JSONResponse( + status_code=400, + content={ + "error": { + "type": "unsupported_tools", + "message": "The request uses tools unsupported by the model upstream.", + } + }, + ) + rid = "resp_" + uuid.uuid4().hex[:24] + model = body.get("model") + 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), + 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) + if r.status_code != 200: + 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)) + + return app + + +async def _chat_passthrough_stream(config: ProxyConfig, base: str, headers: dict, body: dict): + """chat SSE 字节级透传(不转换),让前缀缓存/流式格式与直连一致。""" + try: + async with httpx.AsyncClient(timeout=config.timeout) as c: + async with c.stream( + "POST", f"{base}/chat/completions", json=body, headers=headers + ) as r: + async for line in r.aiter_lines(): + yield line + "\n" + except Exception as exc: # noqa: BLE001 + logger.warning("chat upstream stream failed", exc_info=exc) + yield 'data: {"error":"The model upstream stream failed."}\n\n' + + +async def _stream_gen( + config: ProxyConfig, + base: str, + headers: dict, + chat_req: dict, + rid: str, + model, + cancel: threading.Event, + restore_map: dict | None = None, +): + s = Streamer(rid, model, restore_map) + for e in s.start(): + yield e + try: + # 超时必须有界(config.timeout):无限超时的活动 SSE 会让 stop() 后线程仍存活 + async with httpx.AsyncClient(timeout=config.timeout) as c: + async with c.stream( + "POST", f"{base}/chat/completions", json=chat_req, headers=headers + ) as r: + if r.status_code != 200: + logger.warning("responses upstream stream rejected: status=%s", r.status_code) + yield Streamer.ev( + "response.failed", + { + "type": "response.failed", + "response": { + **s._resp("failed"), + "error": {"message": "The model upstream rejected the request."}, + }, + }, + ) + return + async for line in r.aiter_lines(): + if cancel.is_set(): + return # stop() 置位:主动中断活动 SSE,让线程能及时回收 + line = line.strip() + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + break + try: + chunk = json.loads(data) + except ValueError: + continue + for e in s.handle(chunk): + yield e + except Exception as exc: # noqa: BLE001 + logger.warning("responses upstream stream failed", exc_info=exc) + yield Streamer.ev( + "response.failed", + { + "type": "response.failed", + "response": { + **s._resp("failed"), + "error": {"message": "The model upstream stream failed."}, + }, + }, + ) + return + for e in s.finalize(): + yield e + + +class ProxyServer: + """进程内 localhost 转换 proxy 的生命周期管理。 + + - start 幂等(已启动直接返回)+ 加锁,避免重复 start 覆盖 server/thread 造成泄漏; + - 端口在 start 时 bind 并**一直持有 socket**(传给 uvicorn),避免"先释放再重绑"的 TOCTOU; + - 启动超时与 stop 都会干净回收线程与 socket。 + """ + + def __init__(self, config: ProxyConfig, host: str = "127.0.0.1", port: int = 0): + if host not in _LOOPBACK_HOSTS and not config.local_token: + raise ValueError( + f"非回环监听(host={host})必须设置 local_token,否则是无鉴权代持上游凭证的开放代理" + ) + self.config = config + self.host = host + self._requested_port = port + self.port = port + self._lock = threading.Lock() + self._sock: socket.socket | None = None + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + self._app: FastAPI | None = None + + @property + def base_url(self) -> str: + return f"http://{self.host}:{self.port}/v1" + + def start(self, wait_ready: float = 10.0) -> str: + with self._lock: + if self._server is not None: + return self.base_url # 幂等 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((self.host, self._requested_port)) + except OSError: + sock.close() + raise + self.port = int(sock.getsockname()[1]) + app = create_app(self.config) + self._app = app + # timeout_graceful_shutdown 设上限:stop 时挂在 yield 的 SSE generator 不再消费, + # 若无限等待连接完成(None 默认)会让 shutdown 卡死、线程回收不掉 + cfg = uvicorn.Config(app, log_level="warning", timeout_graceful_shutdown=1) + self._sock = sock + server = uvicorn.Server(cfg) + self._server = server + # serve(sockets=[sock]) 跨平台:uvicorn 直接复用已绑定 socket, + # 不走 Config(fd=...) 那条仅 Unix 可用的 socket.fromfd 路径 + self._thread = threading.Thread( + target=lambda: asyncio.run(server.serve(sockets=[sock])), daemon=True + ) + self._thread.start() + deadline = time.time() + wait_ready + while time.time() < deadline: + try: + if ( + httpx.get(f"http://{self.host}:{self.port}/healthz", timeout=1).status_code + == 200 + ): + return self.base_url + except Exception: # noqa: BLE001 + time.sleep(0.05) + self._teardown() # 启动超时也要清理线程与 socket + raise RuntimeError(f"ProxyServer 未在 {wait_ready}s 内就绪") + + def stop(self): + with self._lock: + self._teardown() + + def _teardown(self): + if self._app is not None: + # 应用层取消信号:让活动 SSE 的 _stream_gen 主动 break + self._app.state.cancel_event.set() + if self._server is not None: + # should_exit 触发主循环进入 shutdown;force_exit 让 shutdown 跳过等待 + # connections/tasks 完成(活动 SSE 下不再无限等)。两者缺一:只 should_exit + # 会被活动连接卡死,只 force_exit 主循环根本不进 shutdown。 + self._server.should_exit = True + self._server.force_exit = True + if self._thread is not None: + self._thread.join(timeout=5) + if self._sock is not None: + try: + self._sock.close() + except OSError: + pass + self._server = None + self._thread = None + self._sock = None + self._app = None diff --git a/ksadk/model_proxy/transform.py b/ksadk/model_proxy/transform.py new file mode 100644 index 00000000..fe979ddd --- /dev/null +++ b/ksadk/model_proxy/transform.py @@ -0,0 +1,691 @@ +"""OpenAI Responses API <-> Chat Completions API 纯转换器(无环境依赖). + +映射逻辑参照 cc-switch transform_codex_chat.rs / streaming_codex_chat.rs / +codex_responses_sse.rs,用 Python 重写。本模块只做纯函数/纯状态机转换, +不读环境变量、不持有网络/配置 —— 配置由 config.py 注入,便于内化与测试。 +""" + +import json +import time + + +class UnsupportedToolsError(ValueError): + """codex 请求携带了当前不支持转换的工具类型(namespace/MCP/web_search 等)。 + + 应 fail-fast 返回明确错误,而不是静默丢弃工具降级请求;根治靠 namespace 拍平。 + """ + + +# --------------------------------------------------------------------------- +# 文本 / role / system 处理 +# --------------------------------------------------------------------------- + + +def extract_text(content): + """content: str | [{type,text}] -> str""" + if isinstance(content, str): + return content + parts = [] + for p in content or []: + if isinstance(p, dict) and p.get("text"): + parts.append(p["text"]) + return "".join(parts) + + +def _chat_role(role): + """responses role -> chat role(developer->system; latest_reminder/未知->user)。 + + kimi-k3 等严格网关对 developer role 报 tokenization failed,必须映射。 + """ + if role in ("system", "developer"): + return "system" + if role == "assistant": + return "assistant" + if role == "tool": + return "tool" + return "user" + + +def collapse_system(msgs): + """把所有 system 消息合并到头部一条(cc-switch collapse_system_messages_to_head)。""" + chunks, rest = [], [] + for m in msgs: + if m.get("role") == "system" and isinstance(m.get("content"), str) and m["content"].strip(): + chunks.append(m["content"]) + else: + rest.append(m) + return ([{"role": "system", "content": "\n\n".join(chunks)}] if chunks else []) + rest + + +# --------------------------------------------------------------------------- +# 请求: responses -> chat +# --------------------------------------------------------------------------- + +# 除 messages/tools 外原样透传的字段(cc-switch EXTRA_CHAT_PASSTHROUGH_FIELDS 子集) +_PASSTHROUGH = ( + "temperature", + "top_p", + "top_logprobs", + "logprobs", + "stop", + "seed", + "frequency_penalty", + "presence_penalty", + "logit_bias", + "response_format", + "user", + "metadata", + "n", + "service_tier", +) + + +def input_to_messages(inp): + """responses 的 input(str | [items]) -> chat messages[]。 + + 处理 message / function_call / function_call_output;reasoning 历史不转发。 + """ + if inp is None: + return [] + if isinstance(inp, str): + return [{"role": "user", "content": inp}] + if isinstance(inp, dict): + inp = [inp] + messages = [] + pending_tc = [] + + def flush(): + if pending_tc: + messages.append({"role": "assistant", "content": None, "tool_calls": list(pending_tc)}) + pending_tc.clear() + + for item in inp: + if not isinstance(item, dict): + continue + t = item.get("type") + if t in (None, "message"): + flush() + messages.append( + { + "role": _chat_role(item.get("role", "user")), + "content": extract_text(item.get("content")), + } + ) + elif t == "function_call": + pending_tc.append( + { + "id": item.get("call_id") or f"call_{len(pending_tc)}", + "type": "function", + "function": { + "name": item.get("name"), + "arguments": item.get("arguments") or "", + }, + } + ) + elif t == "function_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), + } + ) + # reasoning / 其他类型: 跳过 + flush() + return messages + + +def convert_tools(tools): + """codex 扁平 function tool -> chat 嵌套 function tool,保留 strict。 + + 遇非 function 工具(namespace/MCP/web_search 等)fail-fast 抛 UnsupportedToolsError, + 不静默丢弃降级请求(codex 端禁用配置注入前的安全网)。 + """ + out = [] + unsupported = [] + for t in tools or []: + if isinstance(t, dict) and t.get("type") == "function": + fn = { + "name": t.get("name"), + "description": t.get("description", ""), + "parameters": t.get("parameters", {"type": "object", "properties": {}}), + } + if "strict" in t: + fn["strict"] = t["strict"] + out.append({"type": "function", "function": fn}) + else: + t = t or {} + unsupported.append(t.get("type") or t.get("name") or "unknown") + if unsupported: + raise UnsupportedToolsError( + f"不支持转换的 codex 工具类型: {unsupported};" + "请在 codex 端禁用 multi_agent/web_search(v2 优先),或实现 namespace 拍平" + ) + return out + + +def convert_tool_choice(tc): + """responses tool_choice -> chat tool_choice。 + + named 形式 {"type":"function","name":"x"} 必须嵌套为 + {"type":"function","function":{"name":"x"}}(chat 要求 function.name 嵌套)。 + """ + if tc is None or isinstance(tc, str): + return tc # auto / none / required + if isinstance(tc, dict) and tc.get("type") == "function": + name = tc.get("name") or (tc.get("function") or {}).get("name") + return {"type": "function", "function": {"name": name}} + return tc + + +def _convert_text_format(fmt): + """responses text.format -> chat response_format(structured output 结构重组)。 + + responses: {"type":"json_schema","name":...,"schema":...,"strict":...} + chat: {"type":"json_schema","json_schema":{"name":...,"schema":...,"strict":...}} + """ + if not isinstance(fmt, dict): + return None + t = fmt.get("type") + if t == "json_schema": + js = {"name": fmt.get("name"), "schema": fmt.get("schema")} + if "strict" in fmt: + js["strict"] = fmt["strict"] + return {"type": "json_schema", "json_schema": js} + if t in ("json_object", "text"): + return {"type": t} + return None + + +def responses_to_chat(body): + """responses 请求 -> chat 请求;返回 (chat_req, restore_map)。 + + 先拍平 namespace 工具(namespace.py),再用 convert_tools 转 chat 嵌套 function。 + restore_map 供响应侧把 flat function_call 名字还原回 {name, namespace}。 + """ + from .namespace import flatten_request_namespaces + + restore_map = flatten_request_namespaces(body) + out = {"model": body.get("model")} + msgs = [] + if body.get("instructions"): + msgs.append({"role": "system", "content": extract_text(body["instructions"])}) + msgs += input_to_messages(body.get("input")) + out["messages"] = collapse_system(msgs) + if body.get("max_output_tokens") is not None: + out["max_tokens"] = body["max_output_tokens"] + for k in _PASSTHROUGH: + if k in body: + out[k] = body[k] + # codex 特有字段(client.rs:862 固定发送): + # reasoning effort / structured output(在 text.format,非顶层 response_format) / 显式缓存 key + reasoning = body.get("reasoning") + if isinstance(reasoning, dict) and reasoning.get("effort"): + out["reasoning_effort"] = reasoning["effort"] + text = body.get("text") + if isinstance(text, dict): + rf = _convert_text_format(text.get("format")) + if rf is not None: + out["response_format"] = rf + if text.get("verbosity"): + out["verbosity"] = text["verbosity"] + if body.get("prompt_cache_key"): + out["prompt_cache_key"] = body["prompt_cache_key"] + tools = convert_tools(body.get("tools")) + if tools: + out["tools"] = tools + if "tool_choice" in body: + out["tool_choice"] = convert_tool_choice(body["tool_choice"]) + if "parallel_tool_calls" in body: + out["parallel_tool_calls"] = body["parallel_tool_calls"] + return out, restore_map + + +# --------------------------------------------------------------------------- +# 响应(非流式): chat -> responses +# --------------------------------------------------------------------------- + + +def convert_usage(u): + """chat usage -> responses usage;透传前缀缓存命中数(否则 codex 看到 cached 恒 0)。""" + u = u or {} + ct = u.get("completion_tokens_details") or {} + pt = u.get("prompt_tokens_details") or {} # 平台有时返回 None + return { + "input_tokens": u.get("prompt_tokens", 0), + "input_tokens_details": {"cached_tokens": pt.get("cached_tokens", 0)}, + "output_tokens": u.get("completion_tokens", 0), + "total_tokens": u.get("total_tokens", 0), + "output_tokens_details": {"reasoning_tokens": ct.get("reasoning_tokens", 0)}, + } + + +def _status_and_incomplete(finish_reason): + """finish_reason -> (status, incomplete_reason)。 + + length/content_filter 都不是 completed。 + """ + if finish_reason == "length": + return "incomplete", "max_output_tokens" + if finish_reason == "content_filter": + return "incomplete", "content_filter" + return "completed", None + + +def chat_to_response(chat, rid, restore_map=None): + choice = (chat.get("choices") or [{}])[0] + msg = choice.get("message") or {} + output = [] + rc = msg.get("reasoning_content") + if rc: + output.append( + { + "id": f"rs_{rid}", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": rc}], + } + ) + if msg.get("content"): + output.append( + { + "id": f"{rid}_msg", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": msg["content"], "annotations": []}], + } + ) + from .namespace import restore_function_call + + for i, tc in enumerate(msg.get("tool_calls") or []): + fn = tc.get("function") or {} + cid = tc.get("id") or f"call_{i}" + item = restore_function_call( + { + "id": f"fc_{cid}", + "type": "function_call", + "status": "completed", + "call_id": cid, + "name": fn.get("name"), + "arguments": fn.get("arguments") or "", + }, + restore_map or {}, + ) + output.append(item) + status, incomplete = _status_and_incomplete(choice.get("finish_reason")) + resp = { + "id": rid, + "object": "response", + "created_at": chat.get("created", int(time.time())), + "status": status, + "model": chat.get("model"), + "output": output, + "usage": convert_usage(chat.get("usage")), + } + if incomplete: + resp["incomplete_details"] = {"reason": incomplete} + return resp + + +# --------------------------------------------------------------------------- +# 流式: chat SSE -> responses SSE(每个 tool call 独立状态,修复并行截断) +# --------------------------------------------------------------------------- + + +class Streamer: + """把 chat completions 流式 chunk 转成 responses SSE 事件。 + + 关键不变量:每个 tool call(按 index)独立维护 open/output_index/arguments, + **index 切换时不互相关闭**,统一在 finalize 关闭 —— 修复并行 tool call 参数被截断。 + """ + + def __init__(self, rid, model, restore_map=None): + self.rid = rid + self.model = model + self.restore_map = restore_map or {} + self.created = int(time.time()) + self._next_oi = 0 + self._reasoning = None # {"oi","id","text"} + self._message = None # {"oi","id","text"} + self._tool_calls = {} # index -> {"oi","item_id","call_id","name","args"} + self._completed = [] # [(oi, item)] 完成顺序 + self.usage = None + self.finish = None + + # ---- SSE 信封(格式照 codex_responses_sse.rs) ---- + @staticmethod + def ev(event, data): + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + def _resp(self, status, output=None, usage=None): + r = { + "id": self.rid, + "object": "response", + "created_at": self.created, + "status": status, + "model": self.model, + "output": output or [], + } + if usage is not None: + r["usage"] = usage + return r + + def _alloc_oi(self): + oi = self._next_oi + self._next_oi += 1 + return oi + + def start(self): + return [ + self.ev( + "response.created", + {"type": "response.created", "response": self._resp("in_progress")}, + ), + self.ev( + "response.in_progress", + {"type": "response.in_progress", "response": self._resp("in_progress")}, + ), + ] + + # ---- reasoning ---- + def _open_reasoning(self): + oi = self._alloc_oi() + rid = f"rs_{self.rid}" + self._reasoning = {"oi": oi, "id": rid, "text": ""} + return [ + self.ev( + "response.output_item.added", + { + "type": "response.output_item.added", + "output_index": oi, + "item": { + "id": rid, + "type": "reasoning", + "status": "in_progress", + "summary": [], + }, + }, + ), + self.ev( + "response.reasoning_summary_part.added", + { + "type": "response.reasoning_summary_part.added", + "item_id": rid, + "output_index": oi, + "summary_index": 0, + "part": {"type": "summary_text", "text": ""}, + }, + ), + ] + + def _close_reasoning(self): + r = self._reasoning + self._reasoning = None + item = { + "id": r["id"], + "type": "reasoning", + "summary": [{"type": "summary_text", "text": r["text"]}], + } + self._completed.append((r["oi"], item)) + return [ + self.ev( + "response.reasoning_summary_text.done", + { + "type": "response.reasoning_summary_text.done", + "item_id": r["id"], + "output_index": r["oi"], + "summary_index": 0, + "text": r["text"], + }, + ), + self.ev( + "response.reasoning_summary_part.done", + { + "type": "response.reasoning_summary_part.done", + "item_id": r["id"], + "output_index": r["oi"], + "summary_index": 0, + "part": {"type": "summary_text", "text": r["text"]}, + }, + ), + self.ev( + "response.output_item.done", + {"type": "response.output_item.done", "output_index": r["oi"], "item": item}, + ), + ] + + # ---- message ---- + def _open_message(self): + oi = self._alloc_oi() + mid = f"{self.rid}_msg" + self._message = {"oi": oi, "id": mid, "text": ""} + return [ + self.ev( + "response.output_item.added", + { + "type": "response.output_item.added", + "output_index": oi, + "item": { + "id": mid, + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + }, + }, + ), + self.ev( + "response.content_part.added", + { + "type": "response.content_part.added", + "item_id": mid, + "output_index": oi, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": []}, + }, + ), + ] + + def _close_message(self): + m = self._message + self._message = None + item = { + "id": m["id"], + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": m["text"], "annotations": []}], + } + self._completed.append((m["oi"], item)) + return [ + self.ev( + "response.output_text.done", + { + "type": "response.output_text.done", + "item_id": m["id"], + "output_index": m["oi"], + "content_index": 0, + "text": m["text"], + }, + ), + self.ev( + "response.content_part.done", + { + "type": "response.content_part.done", + "item_id": m["id"], + "output_index": m["oi"], + "content_index": 0, + "part": {"type": "output_text", "text": m["text"], "annotations": []}, + }, + ), + self.ev( + "response.output_item.done", + {"type": "response.output_item.done", "output_index": m["oi"], "item": item}, + ), + ] + + # ---- function_call(每个 index 独立) ---- + def _open_tool_call(self, idx, call_id, name): + oi = self._alloc_oi() + item_id = f"fc_{call_id}" + self._tool_calls[idx] = { + "oi": oi, + "item_id": item_id, + "call_id": call_id, + "name": name, + "args": "", + } + return [ + self.ev( + "response.output_item.added", + { + "type": "response.output_item.added", + "output_index": oi, + "item": { + "id": item_id, + "type": "function_call", + "status": "in_progress", + "call_id": call_id, + "name": name, + "arguments": "", + }, + }, + ) + ] + + def _close_tool_call(self, idx): + from .namespace import restore_function_call + + tc = self._tool_calls.pop(idx) + item = restore_function_call( + { + "id": tc["item_id"], + "type": "function_call", + "status": "completed", + "call_id": tc["call_id"], + "name": tc["name"], + "arguments": tc["args"], + }, + self.restore_map, + ) + self._completed.append((tc["oi"], item)) + return [ + self.ev( + "response.function_call_arguments.done", + { + "type": "response.function_call_arguments.done", + "item_id": tc["item_id"], + "output_index": tc["oi"], + "arguments": tc["args"], + }, + ), + self.ev( + "response.output_item.done", + {"type": "response.output_item.done", "output_index": tc["oi"], "item": item}, + ), + ] + + def handle(self, chunk): + evts = [] + if chunk.get("usage"): + self.usage = chunk["usage"] + for choice in chunk.get("choices") or []: + delta = choice.get("delta") or {} + + rt = delta.get("reasoning_content") or delta.get("reasoning") + if rt: + if self._reasoning is None: + evts += self._open_reasoning() + self._reasoning["text"] += rt + evts.append( + self.ev( + "response.reasoning_summary_text.delta", + { + "type": "response.reasoning_summary_text.delta", + "item_id": self._reasoning["id"], + "output_index": self._reasoning["oi"], + "summary_index": 0, + "delta": rt, + }, + ) + ) + + ct = delta.get("content") + if ct: + if self._reasoning is not None: + evts += self._close_reasoning() + if self._message is None: + evts += self._open_message() + self._message["text"] += ct + evts.append( + self.ev( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "item_id": self._message["id"], + "output_index": self._message["oi"], + "content_index": 0, + "delta": ct, + }, + ) + ) + + tcs = delta.get("tool_calls") + if tcs: + # 进入工具调用阶段:reasoning/message 与 tool call 不并行,先关掉 + if self._reasoning is not None: + evts += self._close_reasoning() + if self._message is not None: + evts += self._close_message() + for tc in tcs: + idx = tc.get("index", 0) + fn = tc.get("function") or {} + if idx not in self._tool_calls: + evts += self._open_tool_call( + idx, tc.get("id") or f"call_{idx}", fn.get("name") or "" + ) + elif fn.get("name") and not self._tool_calls[idx]["name"]: + self._tool_calls[idx]["name"] = fn["name"] + if fn.get("arguments"): + self._tool_calls[idx]["args"] += fn["arguments"] + evts.append( + self.ev( + "response.function_call_arguments.delta", + { + "type": "response.function_call_arguments.delta", + "item_id": self._tool_calls[idx]["item_id"], + "output_index": self._tool_calls[idx]["oi"], + "delta": fn["arguments"], + }, + ) + ) + + if choice.get("finish_reason"): + self.finish = choice["finish_reason"] + return evts + + def finalize(self): + evts = [] + if self._reasoning is not None: + evts += self._close_reasoning() + if self._message is not None: + evts += self._close_message() + for idx in sorted(self._tool_calls): + evts += self._close_tool_call(idx) + items = [item for _, item in sorted(self._completed, key=lambda x: x[0])] + if self.finish is None: + # 断流:无合法 incomplete reason(官方仅 max_output_tokens/content_filter),发 failed + resp = self._resp("failed", output=items, usage=convert_usage(self.usage)) + resp["error"] = {"message": "上游流中断(未收到 finish_reason)"} + evts.append(self.ev("response.failed", {"type": "response.failed", "response": resp})) + return evts + status, incomplete = _status_and_incomplete(self.finish) + resp = self._resp(status, output=items, usage=convert_usage(self.usage)) + if incomplete: + resp["incomplete_details"] = {"reason": incomplete} + # codex 仅在收到 response.incomplete 时才报错;incomplete 不能误发 completed + event = "response.completed" if status == "completed" else "response.incomplete" + evts.append(self.ev(event, {"type": event, "response": resp})) + return evts diff --git a/ksadk/openclaw_gateway.py b/ksadk/openclaw_gateway.py index 68b468bd..6440142a 100644 --- a/ksadk/openclaw_gateway.py +++ b/ksadk/openclaw_gateway.py @@ -15,7 +15,6 @@ from ksadk.api import AgentEngineClient from ksadk.version import VERSION as KSADK_VERSION - DEFAULT_CLIENT_NAME = "openclaw-control-ui" DEFAULT_CLIENT_MODE = "webchat" DEFAULT_SCOPES = ("operator.admin",) @@ -152,7 +151,9 @@ async def build_access_info( cookie_header = self._build_cookie_header() if not cookie_header: - raise OpenClawGatewayError("Dashboard short-link bootstrap did not produce a session cookie") + raise OpenClawGatewayError( + "Dashboard short-link bootstrap did not produce a session cookie" + ) parsed = urlsplit(access_url) self.connection_info = DashboardAccessInfo( @@ -208,7 +209,9 @@ async def connect( ) return self.hello - async def channels_status(self, *, probe: bool = False, timeout_ms: Optional[int] = None) -> dict[str, Any]: + async def channels_status( + self, *, probe: bool = False, timeout_ms: Optional[int] = None + ) -> dict[str, Any]: params: dict[str, Any] = {"probe": bool(probe)} if timeout_ms is not None: params["timeoutMs"] = int(timeout_ms) @@ -247,7 +250,9 @@ async def web_login_start( params: dict[str, Any] = {"force": bool(force)} if timeout_ms is not None: params["timeoutMs"] = int(timeout_ms) - return await self.request("web.login.start", params, timeout_ms=(timeout_ms or 30_000) + 5_000) + return await self.request( + "web.login.start", params, timeout_ms=(timeout_ms or 30_000) + 5_000 + ) async def web_login_wait( self, @@ -262,7 +267,9 @@ async def web_login_wait( params["accountId"] = effective_account_id if timeout_ms is not None: params["timeoutMs"] = int(timeout_ms) - return await self.request("web.login.wait", params, timeout_ms=(timeout_ms or 120_000) + 5_000) + return await self.request( + "web.login.wait", params, timeout_ms=(timeout_ms or 120_000) + 5_000 + ) async def request( self, @@ -285,7 +292,7 @@ async def request( return await self._wait_for_response(request_id, timeout_ms=timeout_ms) async def wait_for_disconnect(self, *, timeout_ms: int = 5_000) -> bool: - """Wait briefly for the current websocket to close, typically after config-triggered restart.""" + """Wait for the websocket to close after a config-triggered restart.""" if self._ws is None: return True @@ -311,16 +318,24 @@ async def _connect_ws(self, ws_url: str, headers: dict[str, str]): "Missing dependency `websockets`; reinstall ksadk with channel support enabled" ) from exc - connect_kwargs = { - "open_timeout": 10, - "ping_interval": 20, - "ping_timeout": 20, - "max_size": 50 * 1024 * 1024, - } try: - return await websockets.connect(ws_url, additional_headers=headers, **connect_kwargs) + return await websockets.connect( + ws_url, + additional_headers=headers, + open_timeout=10, + ping_interval=20, + ping_timeout=20, + max_size=50 * 1024 * 1024, + ) except TypeError: - return await websockets.connect(ws_url, extra_headers=headers, **connect_kwargs) + return await websockets.connect( + ws_url, + extra_headers=headers, + open_timeout=10, + ping_interval=20, + ping_timeout=20, + max_size=50 * 1024 * 1024, + ) async def _wait_for_connect_challenge(self, *, timeout_ms: int = 10_000) -> str: deadline = timeout_ms / 1000 diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index beab7e5b..748efcdf 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -15,11 +15,11 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from pathlib import Path -from typing import Any, AsyncIterator, Dict, Mapping, Optional +from typing import Any, AsyncIterator, Dict, Mapping, Optional, cast -from google.genai import types from opentelemetry import trace +from ksadk.compat.adk_compat import genai_types as types from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes from ksadk.conversations.model_context import supports_native_image_input from ksadk.runners.base_runner import BaseRunner @@ -31,22 +31,36 @@ tracer = trace.get_tracer(__name__) +def _part_metadata_flag(part: Any, key: str) -> bool: + """读取 A2A→GenAI Part 保留下来的扩展 metadata 标记。""" + metadata = getattr(part, "part_metadata", None) + if isinstance(metadata, Mapping): + return bool(metadata.get(key)) + getter = getattr(metadata, "get", None) + if callable(getter): + try: + return bool(getter(key)) + except (KeyError, TypeError, ValueError): + return False + return False + + class ADKRunner(BaseRunner): """ADK 框架运行时""" def __init__(self, detection_result: Any, project_dir: str): super().__init__(detection_result, project_dir) - self._runner = None - self._session_service = None + self._runner: Any = None + self._session_service: Any = None # Map external session_ids (e.g. from run_interactive or web) to ADK internal session IDs self._session_map: Dict[str, str] = {} # Fallback default session self._default_session_id: Optional[str] = None # Memory integration - self._short_term_memory = None - self._long_term_memory = None + self._short_term_memory: Any = None + self._long_term_memory: Any = None # Knowledge base integration - self._knowledge_base = None + self._knowledge_base: Any = None # Keep runtime toolsets alive for the lifetime of the runner. self._runtime_toolsets: list[Any] = [] # ADK resumability state @@ -55,7 +69,7 @@ def __init__(self, detection_result: Any, project_dir: str): # P1.1 sub-issue: guard invocation_map read-modify-write so concurrent # invocations on the same session don't lose each other's mappings. self._invocation_map_lock = asyncio.Lock() - self._module = None + self._module: Any = None self._adk_resume_min_version = os.environ.get( "GOOGLE_ADK_RESUME_MIN_VERSION", "1.16.0" ).strip() @@ -98,7 +112,9 @@ def _apply_json_patch(self): import inspect as _inspect import json as _stdlib_json - import google.adk.models.lite_llm as adk_lite_llm + from ksadk.compat.adk_compat import lite_llm_module + + adk_lite_llm = lite_llm_module() _original_fn = adk_lite_llm._message_to_generate_content_response if getattr(_original_fn, "__ksadk_json_patch__", False): @@ -106,9 +122,7 @@ def _apply_json_patch(self): # Determine which kwargs the original function actually accepts, # so the patch is compatible across ADK versions that may have # added or removed `model_version` / `thought_parts`. - _orig_params = set( - _inspect.signature(_original_fn).parameters.keys() - ) + _orig_params = set(_inspect.signature(_original_fn).parameters.keys()) def _patched_message_to_generate_content_response( message, *, is_partial=False, model_version=None, thought_parts=None @@ -129,8 +143,8 @@ def _patched_message_to_generate_content_response( "ADKRunner: caught JSONDecodeError in args parsing, " "returning empty response" ) - from google.adk.models import LlmResponse - from google.genai import types as _genai_types + from ksadk.compat.adk_compat import LlmResponse + from ksadk.compat.adk_compat import genai_types as _genai_types return LlmResponse( content=_genai_types.Content(role="model", parts=[]), @@ -149,15 +163,11 @@ def _patched_message_to_generate_content_response( for i, part in enumerate(result.content.parts): if part.function_call and part.function_call.args is None: part.function_call.args = {} - if ( - part.function_call - and not (part.function_call.name or "").strip() - ): + if part.function_call and not (part.function_call.name or "").strip(): phantom_indices.add(i) if phantom_indices: result.content.parts = [ - p for i, p in enumerate(result.content.parts) - if i not in phantom_indices + p for i, p in enumerate(result.content.parts) if i not in phantom_indices ] return result @@ -180,7 +190,6 @@ def _patched_message_to_generate_content_response( except Exception: pass - def _apply_mcp_result_patch(self): """Patch ADK McpTool to convert CallToolResult to dict. @@ -192,17 +201,17 @@ def _apply_mcp_result_patch(self): for the old version. """ try: - from google.adk.tools.mcp_tool.mcp_tool import McpTool + from ksadk.compat.adk_compat import McpTool _original_run_async = McpTool._run_async_impl if getattr(_original_run_async, "__ksadk_mcp_result_patch__", False): return - async def _patched_run_async_impl( - self, *, args, tool_context, credential - ): + async def _patched_run_async_impl(self, *, args, tool_context, credential): response = await _original_run_async( - self, args=args, tool_context=tool_context, + self, + args=args, + tool_context=tool_context, credential=credential, ) # If response is a Pydantic model (e.g. CallToolResult), @@ -224,7 +233,6 @@ async def _patched_run_async_impl( except Exception as exc: logger.debug("ADKRunner: MCP result patch failed: %s", exc) - def _init_short_term_memory(self): """从环境变量初始化短期记忆 @@ -268,8 +276,7 @@ def get_session_adapter(self): def describe_checkpoint_capability(self) -> dict[str, Any]: resumable = getattr(self, "_resumable", False) stm_backend = ( - getattr(self._short_term_memory, "backend", None) - if self._short_term_memory else None + getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None ) if resumable: backend = "adk_invocation" @@ -312,8 +319,7 @@ def get_runtime_capabilities(self) -> dict[str, Any]: capabilities = super().get_runtime_capabilities() resumable = getattr(self, "_resumable", False) stm_backend = ( - getattr(self._short_term_memory, "backend", None) - if self._short_term_memory else None + getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None ) is_durable = stm_backend is not None and stm_backend != "local" if resumable: @@ -352,9 +358,11 @@ def get_runtime_capabilities(self) -> dict[str, Any]: "Supported": True, "Type": "native_session" if has_native_session else "semantic_replay", "Level": "semantic", - "Reason": "ADK native session can continue conversation context" - if has_native_session - else "conversation transcript can be replayed", + "Reason": ( + "ADK native session can continue conversation context" + if has_native_session + else "conversation transcript can be replayed" + ), } capabilities["ResumeRun"]["Reason"] = capabilities["Checkpoint"]["Reason"] return capabilities @@ -362,8 +370,8 @@ def get_runtime_capabilities(self) -> dict[str, Any]: @dataclass class _ResolvabilityResult: enabled: bool - source: str # "agent_module" | "env" | "auto_persistent_session" | "default" - app: Any # User module exported App object (if any) + source: str # "agent_module" | "env" | "auto_persistent_session" | "default" + app: Any # User module exported App object (if any) def _resolve_resumability(self) -> "_ResolvabilityResult": """从环境变量或 agent 模块推断是否启用 ADK 可恢复性。""" @@ -371,13 +379,15 @@ def _resolve_resumability(self) -> "_ResolvabilityResult": module = getattr(self, "_module", None) if module is not None: try: - from google.adk.apps import App + from ksadk.compat.adk_compat import App + for attr_name in ("app", "application"): candidate = getattr(module, attr_name, None) if isinstance(candidate, App) and candidate.resumability_config: if candidate.resumability_config.is_resumable: return self._ResolvabilityResult( - enabled=True, source="agent_module", app=candidate) + enabled=True, source="agent_module", app=candidate + ) except ImportError: pass @@ -388,12 +398,12 @@ def _resolve_resumability(self) -> "_ResolvabilityResult": # Priority 3: persistent session backend auto-enable stm_backend = ( - getattr(self._short_term_memory, "backend", None) - if self._short_term_memory else None + getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None ) if stm_backend and stm_backend != "local": return self._ResolvabilityResult( - enabled=True, source="auto_persistent_session", app=None) + enabled=True, source="auto_persistent_session", app=None + ) return self._ResolvabilityResult(enabled=False, source="default", app=None) @@ -417,6 +427,7 @@ def _check_adk_resume_compatibility(self) -> tuple[bool, str]: return False, "google-adk version unknown, cannot guarantee invocation_id support" try: from packaging.version import Version + if Version(adk_ver) < Version(self._adk_resume_min_version): return ( False, @@ -430,7 +441,7 @@ def _check_adk_resume_compatibility(self) -> tuple[bool, str]: def _build_runner(self) -> None: """构造 ADK Runner,优先使用 App 对象以启用 ResumabilityConfig。""" - from google.adk.runners import Runner + from ksadk.compat.adk_compat import Runner resumable = self._resolve_resumability() resumability_enabled = resumable.enabled @@ -438,9 +449,7 @@ def _build_runner(self) -> None: # 版本兼容性检查:低于最低版本时强制关闭恢复 resume_compatible, resume_reason = self._check_adk_resume_compatibility() if not resume_compatible and resumable.enabled: - logger.warning( - "ADKRunner: resumability disabled — %s", resume_reason - ) + logger.warning("ADKRunner: resumability disabled — %s", resume_reason) resumable = self._ResolvabilityResult(enabled=False, source="version_check", app=None) resumability_enabled = False self._resume_disabled_reason = resume_reason @@ -452,7 +461,8 @@ def _build_runner(self) -> None: ) elif resumable.enabled: try: - from google.adk.apps import App, ResumabilityConfig + from ksadk.compat.adk_compat import App, ResumabilityConfig + app = App( name=self._agent.name, root_agent=self._agent, @@ -491,7 +501,8 @@ def _build_runner(self) -> None: if resumability_enabled: stm_backend = ( getattr(self._short_term_memory, "backend", None) - if self._short_term_memory else None + if self._short_term_memory + else None ) logger.info( "ADKRunner: resumability enabled (source=%s, backend=%s)", @@ -519,10 +530,7 @@ def _init_long_term_memory(self): agent_name = self._agent.name if self._agent else "default" ltm = LongTermMemory.from_env(app_name=agent_name) - logger.info( - f"LongTermMemory initialized: backend={backend}, " - f"app_name={agent_name}" - ) + logger.info(f"LongTermMemory initialized: backend={backend}, " f"app_name={agent_name}") return ltm except Exception as e: logger.warning(f"Failed to init LongTermMemory: {e}.") @@ -546,8 +554,7 @@ def _init_knowledge_base(self): kb = KnowledgeBaseClient.from_env() logger.info( - f"KnowledgeBase initialized: dataset_id={kb.dataset_id}, " - f"region={kb.region}" + f"KnowledgeBase initialized: dataset_id={kb.dataset_id}, " f"region={kb.region}" ) return kb except ImportError: @@ -582,7 +589,7 @@ def _inject_search_knowledge_tool(self): def _inject_load_memory_tool(self): """自动注入 load_memory 工具到 Agent""" try: - from google.adk.tools import load_memory + from ksadk.compat.adk_compat import load_memory added = self._append_tools_by_name([load_memory]) if added: @@ -810,7 +817,7 @@ def inject_deferred_tools_for_request( @staticmethod def _tool_name(tool: Any) -> str: - return getattr(tool, "name", None) or getattr(tool, "__name__", "") + return str(getattr(tool, "name", None) or getattr(tool, "__name__", "")) def _append_tools_by_name(self, tools: list[Any]) -> list[str]: if not hasattr(self._agent, "tools"): @@ -821,9 +828,7 @@ def _append_tools_by_name(self, tools: list[Any]) -> list[str]: self._agent.tools = [] existing_names = { - self._tool_name(tool) - for tool in self._agent.tools - if self._tool_name(tool) + self._tool_name(tool) for tool in self._agent.tools if self._tool_name(tool) } added_names: list[str] = [] for tool in tools: @@ -845,9 +850,7 @@ def _append_toolsets_by_key(self, toolsets: list[Any], *, key_attr: str) -> list self._agent.tools = [] existing_keys = { - getattr(tool, key_attr) - for tool in self._agent.tools - if getattr(tool, key_attr, None) + getattr(tool, key_attr) for tool in self._agent.tools if getattr(tool, key_attr, None) } added_keys: list[str] = [] for toolset in toolsets: @@ -879,11 +882,13 @@ def _invalid_agent_name_load_error(self, exc: Exception) -> ValueError | None: name = detail.get("input") if location not in (("name",), ["name"]) or not isinstance(name, str): continue - if "valid identifier" not in message.lower(): + # adk 1.34 报 "...valid identifier";adk 2.x 报 "...valid Python identifier"。 + # 放宽匹配以同时覆盖两种措辞,保证两版都给出 actionable hint。 + msg_lower = message.lower() + if "identifier" not in msg_lower or "valid" not in msg_lower: continue safe_name = "".join( - char if char.isascii() and (char.isalnum() or char == "_") else "_" - for char in name + char if char.isascii() and (char.isalnum() or char == "_") else "_" for char in name ) if not safe_name or safe_name[0].isdigit(): safe_name = f"agent_{safe_name}" @@ -960,7 +965,7 @@ def load_agent(self) -> None: self._inject_search_knowledge_tool() # 初始化 SessionService - from google.adk.sessions import InMemorySessionService + from ksadk.compat.adk_compat import InMemorySessionService if self._short_term_memory: self._session_service = self._short_term_memory.session_service @@ -983,10 +988,7 @@ def load_agent(self) -> None: os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") ) self._default_model_reference = self._discover_model_reference(self._agent) - self._active_model_name = ( - self._default_model_reference - or self._default_model_name - ) + self._active_model_name = self._default_model_reference or self._default_model_name def _discover_model_reference(self, agent: Any) -> Optional[str]: visited: set[int] = set() @@ -1058,18 +1060,14 @@ def _visit(node: Any) -> None: def prepare_for_request(self, model: str | None) -> None: normalized = self.sync_process_model_env(model) if normalized is None: - default_model_name = ( - getattr(self, "_default_model_name", None) - or self.normalize_requested_model( - os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") - ) + default_model_name = getattr( + self, "_default_model_name", None + ) or self.normalize_requested_model( + os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") ) if default_model_name: self.sync_process_model_env(default_model_name) - target_reference = ( - getattr(self, "_default_model_reference", None) - or default_model_name - ) + target_reference = getattr(self, "_default_model_reference", None) or default_model_name if target_reference and self._agent is not None: current_reference = self._discover_model_reference(self._agent) if current_reference != target_reference: @@ -1095,14 +1093,13 @@ def prepare_for_request(self, model: str | None) -> None: return self._active_model_name = target_reference - def _prepare_trace_metadata(self, session_id: str): + def _prepare_trace_metadata(self, session_id: Optional[str]): """准备 Trace 元数据 (Tags, UserID, etc.)""" from ksadk.tracing.span_utils import prepare_trace_metadata - return prepare_trace_metadata( - detection_result=getattr(self, "detection_result", None) - ) - async def _ensure_session(self, external_session_id: str = None) -> str: + return prepare_trace_metadata(detection_result=getattr(self, "detection_result", None)) + + async def _ensure_session(self, external_session_id: Optional[str] = None) -> str: """Get or create ADK session ID based on external ID When ShortTermMemory is configured, uses its create_session method @@ -1121,11 +1118,13 @@ async def _ensure_session(self, external_session_id: str = None) -> str: session_id=external_session_id, ) else: + if self._session_service is None: + raise RuntimeError("ADK session service is not initialized") session = await self._session_service.create_session( app_name=self._agent.name, user_id="ksadk_user" ) self._session_map[external_session_id] = session.id - return session.id + return str(session.id) # Case 2: No external ID (use default singleton) if self._default_session_id is None: @@ -1135,11 +1134,13 @@ async def _ensure_session(self, external_session_id: str = None) -> str: user_id="ksadk_user", ) else: + if self._session_service is None: + raise RuntimeError("ADK session service is not initialized") session = await self._session_service.create_session( app_name=self._agent.name, user_id="ksadk_user" ) self._default_session_id = session.id - return self._default_session_id + return str(self._default_session_id) async def save_session_to_long_term_memory( self, session_id: str, user_id: str = "ksadk_user" @@ -1323,8 +1324,11 @@ def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]: usage_metadata.get("total_token_count") or (input_tokens + output_tokens) ) if not ( - input_tokens or output_tokens or total_tokens - or input_token_details or output_token_details + input_tokens + or output_tokens + or total_tokens + or input_token_details + or output_token_details ): return {} return { @@ -1339,7 +1343,6 @@ def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]: def _extract_event_usage(cls, event: Any) -> dict[str, Any]: return cls._normalize_usage_metadata(getattr(event, "usage_metadata", None)) - # --- ADK invocation_id & checkpoint helpers --- async def _get_max_checkpoint_seq_for_run( @@ -1380,9 +1383,9 @@ async def _get_max_checkpoint_seq_for_run( return max_seq except Exception as exc: logger.warning( - "ADKRunner: failed to query max checkpoint_seq " - "for run_id=%s: %s", - run_id, exc, + "ADKRunner: failed to query max checkpoint_seq " "for run_id=%s: %s", + run_id, + exc, ) return 0 @@ -1406,7 +1409,8 @@ async def _collect_adk_invocation_id( captured_adk_invocation_id = "" # 从已有 checkpoint 的最大 seq 继续,避免恢复模式下 ID 碰撞被去重丢弃 checkpoint_seq = await self._get_max_checkpoint_seq_for_run( - session_id=session_id, run_id=effective_run_id, + session_id=session_id, + run_id=effective_run_id, ) async for event in events_async: if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id: @@ -1513,7 +1517,6 @@ async def _resolve_adk_invocation_id( except Exception: return None - def _is_resumable_boundary(self, event: Any) -> bool: """判断 ADK event 是否为可恢复边界。""" # 工具调用请求 @@ -1527,6 +1530,83 @@ def _is_resumable_boundary(self, event: Any) -> bool: return True return False + def _extract_approval_signals(self, event: Any) -> list[dict[str, Any]]: + """从 ADK event 提取原生审批信号,翻译成统一 interrupt_info(供 runtime:4645 消费)。 + + 覆盖 ADK 两套原生 HITL 机制,经 ``adk_compat`` 能力探测,兼容 1.34.x → 2.x: + + 1. **tool-confirmation**(``actions.requested_tool_confirmations``):工具执行前 + yes/no 审批,1.34+ 均有。→ ``kind="tool"``。 + 2. **workflow HITL**(v2.0+):``event.interrupted=True`` + ``RequestInput`` 信号 + (message/payload/response_schema)。→ ``kind="input"``。 + + 命中任一即返回 ``[{"approval_request_id","tool_name","args","kind","message"}, ...]``, + 否则返回 ``[]``。不引入对 v2.0 API 的硬依赖——字段缺失时跳过,不报错。 + """ + signals: list[dict[str, Any]] = [] + actions = getattr(event, "actions", None) + + # 1) tool-confirmation:requested_tool_confirmations(1.34+,经 getattr 容错) + if actions is not None: + confirmations = getattr(actions, "requested_tool_confirmations", None) + if confirmations: + for conf in confirmations: + if conf is None: + continue + conf_id = str( + getattr(conf, "id", "") or getattr(conf, "approval_request_id", "") or "" + ) + if not conf_id: + continue + signals.append( + { + "approval_request_id": conf_id, + "tool_name": str( + getattr(conf, "tool_name", "") + or getattr(conf, "name", "") + or "tool" + ), + "args": dict(getattr(conf, "args", None) or {}), + "kind": "tool", + "message": str( + getattr(conf, "message", "") or getattr(conf, "hint", "") or "" + ), + } + ) + + # 2) workflow HITL(v2.0+):event.interrupted + RequestInput 信号 + # 仅当 adk_compat 探测到 v2.0+ 时检查,避免在 1.34 上访问不存在字段。 + try: + from ksadk.compat.adk_compat import adk_version_at_least + + if adk_version_at_least("2.0.0") and getattr(event, "interrupted", False): + # RequestInput 信号可能挂在 actions 或 event 上(字段名跨小版本未冻结, + # 用 getattr 容错,缺失即视为纯 interrupt 无 payload)。 + req_input = None + if actions is not None: + req_input = getattr(actions, "requested_input", None) or getattr( + actions, "request_input", None + ) + if req_input is None: + req_input = getattr(event, "requested_input", None) + interrupt_id = "adk-hitl-" + str(getattr(event, "invocation_id", "") or "unknown") + signals.append( + { + "approval_request_id": interrupt_id, + "tool_name": "RequestInput", + "args": dict(getattr(req_input, "payload", None) or {}), + "kind": "input", + "message": str( + getattr(req_input, "message", "") or "ADK workflow 请求人工输入" + ), + } + ) + except Exception: + # 能力探测失败时不阻塞主流(审批 surface 是 best-effort,不降级为报错)。 + pass + + return signals + async def _maybe_write_checkpoint( self, *, @@ -1573,9 +1653,10 @@ async def _maybe_write_checkpoint( metadata=metadata, ) logger.debug( - "ADKRunner: wrote checkpoint adk-ckpt-%d at boundary " - "(session=%s, invocation_id=%s)", - checkpoint_seq, session_id, adk_invocation_id, + "ADKRunner: wrote checkpoint adk-ckpt-%d at boundary " "(session=%s, invocation_id=%s)", + checkpoint_seq, + session_id, + adk_invocation_id, ) def _extract_checkpoint_metadata(self, event: Any) -> dict[str, Any]: @@ -1602,8 +1683,7 @@ def _extract_checkpoint_metadata(self, event: Any) -> dict[str, Any]: # 是否可恢复 stm_backend = ( - getattr(self._short_term_memory, "backend", None) - if self._short_term_memory else None + getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None ) shared_across_pods = stm_backend == "database" platform_resumable = self._resumable and shared_across_pods @@ -1655,9 +1735,9 @@ async def _resolve_resume_invocation_id( ) if not adk_invocation_id: logger.error( - "Resume requested but ADK invocation_id not found for " - "session=%s ksadk_inv=%s", - session_id, ksadk_invocation_id, + "Resume requested but ADK invocation_id not found for " "session=%s ksadk_inv=%s", + session_id, + ksadk_invocation_id, ) raise ValueError( f"checkpoint_not_resumable: ADK invocation_id not found for " @@ -1665,7 +1745,7 @@ async def _resolve_resume_invocation_id( f"The checkpoint data may have been lost or the invocation was " f"never persisted." ) - return adk_invocation_id + return str(adk_invocation_id) async def _prepare_run_events( self, @@ -1691,9 +1771,7 @@ async def _prepare_run_events( else: new_message = None - ksadk_invocation_id = str( - input_data.get("invocation_id") or input_data.get("run_id") or "" - ) + ksadk_invocation_id = str(input_data.get("invocation_id") or input_data.get("run_id") or "") checkpoint_run_id = ( str(input_data.get("run_id") or "").strip() @@ -1725,6 +1803,8 @@ async def _prepare_run_events( run_kwargs["new_message"] = new_message run_kwargs["state_delta"] = self._build_state_delta(input_data) or None + if self._runner is None: + raise RuntimeError("ADK runner is not initialized") events_async = self._runner.run_async(**run_kwargs) if ksadk_invocation_id and self._resumable: @@ -1740,7 +1820,7 @@ async def _prepare_run_events( session_id=session_id, ksadk_invocation_id=ksadk_invocation_id, ) - return wrapped_async + return cast(AsyncIterator[Any], wrapped_async) async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 ADK Agent""" @@ -1764,9 +1844,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # Set input.value for Langfuse top-level input display span.set_attribute("input.value", user_input) truncated_input = ( - user_input[:200] - if isinstance(user_input, str) - else str(user_input)[:200] + user_input[:200] if isinstance(user_input, str) else str(user_input)[:200] ) span.set_attribute("user.input", truncated_input) @@ -1819,7 +1897,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # final_response may hold only intermediate fragments. last_event_text = "" if last_event is not None and hasattr(last_event, "content") and last_event.content: - for part in (last_event.content.parts or []): + for part in last_event.content.parts or []: is_thought = getattr(part, "thought", False) if hasattr(part, "text") and part.text and not is_thought: last_event_text += part.text @@ -1835,7 +1913,10 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # Set output.value for Langfuse top-level output display span.set_attribute("output.value", final_response[:5000] if final_response else "") span.set_attribute("agent.output", final_response[:500] if final_response else "") - result = {"output": final_response, "events": events_list} + result: dict[str, Any] = { + "output": final_response, + "events": events_list, + } if usage: result["usage"] = usage # last_usage = 最后一次 LLM 调用快照(input_tokens = 当前上下文窗口占用) @@ -1847,7 +1928,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An 使用 StreamingMode.SSE 启用真正的流式 token 输出 """ - from google.adk.agents.run_config import RunConfig, StreamingMode + from ksadk.compat.adk_compat import RunConfig, StreamingMode # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理 is_resume = bool(input_data.get("checkpoint_resume")) @@ -1868,9 +1949,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An # Set input.value for Langfuse top-level input display span.set_attribute("input.value", user_input) truncated_input = ( - user_input[:200] - if isinstance(user_input, str) - else str(user_input)[:200] + user_input[:200] if isinstance(user_input, str) else str(user_input)[:200] ) span.set_attribute("user.input", truncated_input) @@ -1899,6 +1978,13 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An accumulated_text = "" usage: dict[str, Any] = {} last_usage: dict[str, Any] = {} + # handoff/sub-agent(如 RemoteA2aAgent)回复事件 partial=None,不进上面 + # partial 分支;且远端 agent 经 A2A 流式返回时,ADK 会产出多个"累积快照" + # 事件(后一个含前一个内容)。按 author 记录已输出快照,只补发增量去重。 + sub_agent_snapshots: dict[str, str] = {} + sub_agent_thought_snapshots: dict[str, str] = {} + sub_agent_last_output_kind: dict[str, str] = {} + top_agent_name = getattr(self._agent, "name", None) async for event in wrapped_async: event_usage = self._extract_event_usage(event) @@ -1908,15 +1994,137 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An # Only yield text delta if event is partial to avoid duplication of final summary if hasattr(event, "content") and event.content and getattr(event, "partial", False): if hasattr(event.content, "parts"): + author = getattr(event, "author", None) + is_sub_agent = bool(author and top_agent_name and author != top_agent_name) + author_key = str(author) for part in event.content.parts: if hasattr(part, "text") and part.text: is_thought = getattr(part, "thought", False) - accumulated_text += part.text + replace_snapshot = bool( + is_sub_agent + and _part_metadata_flag(part, "ksadk_output_snapshot") + ) + output_delta = part.text + # 思考内容只作为 thinking delta 流出,不计入最终输出, + # 否则最终回复会把思考过程再重复一遍(与 invoke() 语义对齐)。 + if is_thought and is_sub_agent: + if sub_agent_last_output_kind.get(author_key) == "text": + # 正文之后的 thought 是一个新的 reasoning segment。 + sub_agent_thought_snapshots[author_key] = "" + previous_thought = sub_agent_thought_snapshots.get( + author_key, "" + ) + if part.text.startswith(previous_thought): + output_delta = part.text[len(previous_thought) :] + sub_agent_thought_snapshots[author_key] = part.text + else: + sub_agent_thought_snapshots[author_key] = ( + previous_thought + part.text + ) + elif not is_thought: + accumulated_text = ( + part.text + if replace_snapshot + else accumulated_text + part.text + ) + # sub-agent(RemoteA2aAgent)增量也累积进快照,供后续 + # completed 全文消息(同一份结果)在 handoff 分支去重。 + if is_sub_agent: + sub_agent_snapshots[author_key] = ( + part.text + if replace_snapshot + else sub_agent_snapshots.get(author_key, "") + part.text + ) # 标记思考内容,前端可以选择是否展示 - yield { - "delta": part.text, - "type": "thinking" if is_thought else "text", - } + if output_delta: + output_chunk: dict[str, Any] = { + "delta": output_delta, + "type": "thinking" if is_thought else "text", + } + if replace_snapshot and not is_thought: + output_chunk["replace"] = True + yield output_chunk + if is_sub_agent: + sub_agent_last_output_kind[author_key] = ( + "thinking" if is_thought else "text" + ) + # handoff/sub-agent 回复:partial 为 None/False,上面分支跳过,这里补上。 + elif hasattr(event, "content") and event.content: + author = getattr(event, "author", None) + if ( + author + and top_agent_name + and author != top_agent_name + and hasattr(event.content, "parts") + ): + author_key = str(author) + for part in event.content.parts: + if ( + hasattr(part, "text") + and part.text + and getattr(part, "thought", False) + ): + # A2A's terminal artifact is converted by ADK to + # partial=False. Once that sub-agent has already + # emitted response text, a trailing thought here is + # a terminal snapshot/replacement (not a new + # interleaved reasoning step). Showing it makes + # the UI appear to end at "thinking" and hides the + # just-emitted final response. + if sub_agent_last_output_kind.get(author_key) == "text": + continue + # RemoteA2aAgent 将 last_chunk=True 映射成 + # partial=False;这可能是一个此前 partial thought + # 的终态快照。只补发新增内容,避免正文之后再显示一遍 + # 相同的思考块;若此前没有 partial thought,仍完整透传。 + previous_thought = sub_agent_thought_snapshots.get( + author_key, "" + ) + if part.text.startswith(previous_thought): + thought_delta = part.text[len(previous_thought) :] + sub_agent_thought_snapshots[author_key] = part.text + elif previous_thought.startswith(part.text): + thought_delta = "" + else: + thought_delta = part.text + sub_agent_thought_snapshots[author_key] = ( + previous_thought + part.text + ) + if thought_delta: + yield {"delta": thought_delta, "type": "thinking"} + snapshot = "" + replace_snapshot = False + for part in event.content.parts: + if hasattr(part, "text") and part.text and not getattr( + part, "thought", False + ): + snapshot += part.text + replace_snapshot = replace_snapshot or _part_metadata_flag( + part, + "ksadk_output_snapshot", + ) + if snapshot: + prev = sub_agent_snapshots.get(author_key, "") + if replace_snapshot: + delta = snapshot + sub_agent_snapshots[author_key] = snapshot + accumulated_text = snapshot + elif snapshot.startswith(prev): + # 累计快照(如 completed 全文):只补发超出已累积的部分; + # 若增量已发全,delta 为空,避免 completed 再渲染一遍。 + delta = snapshot[len(prev) :] + sub_agent_snapshots[author_key] = snapshot + else: + # final 增量(小块):追加进累积,不覆盖。 + delta = snapshot + sub_agent_snapshots[author_key] = prev + snapshot + if delta: + if not replace_snapshot: + accumulated_text += delta + output_chunk = {"delta": delta, "type": "text"} + if replace_snapshot: + output_chunk["replace"] = True + yield output_chunk # 处理工具调用事件 — ADK 通过 event.content.parts[].function_call # 发出工具调用(即 event.get_function_calls()),而非 @@ -1935,6 +2143,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if not isinstance(fc_args, dict): try: import json as _json + fc_args = _json.loads(fc_args) if isinstance(fc_args, str) else {} except Exception: fc_args = {} @@ -1958,6 +2167,21 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An "tool_args": getattr(tool_call, "input", {}), } + # ADK 原生审批 surface(tool-confirmation + v2.0 workflow HITL): + # 翻译成统一 interrupt chunk,经 runtime:4645 既有 approval_request 通道 + # 消费 → 审批卡。能力探测兼容 1.34.x→2.x,缺字段则跳过。 + emitted_approval_ids: set[str] = set() + for signal in self._extract_approval_signals(event): + aid = signal.get("approval_request_id", "") + if not aid or aid in emitted_approval_ids: + continue + emitted_approval_ids.add(aid) + yield { + "type": "interrupt", + "interrupt_info": signal, + "session_id": session_id, + } + # 处理工具返回结果 — ADK 通过 event.content.parts[].function_response # 发出工具执行结果。当工具执行完毕后 ADK 会产生一个包含 # function_response 的事件,此处将其转为 tool_result 语义事件, @@ -1971,6 +2195,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if not isinstance(fr_output, dict): try: import json as _json2 + if isinstance(fr_output, str): fr_output = _json2.loads(fr_output) else: diff --git a/ksadk/runners/base_runner.py b/ksadk/runners/base_runner.py index a804e1a3..a36a5f98 100644 --- a/ksadk/runners/base_runner.py +++ b/ksadk/runners/base_runner.py @@ -18,7 +18,8 @@ class BaseRunner(ABC): def __init__(self, detection_result: Any, project_dir: str): self.detection_result = detection_result self.project_dir = project_dir - self._agent = None + # Framework runners populate this with SDK-specific agent/graph objects. + self._agent: Any = None @abstractmethod def load_agent(self) -> None: @@ -38,7 +39,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: pass @abstractmethod - async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: + def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: """流式调用 Agent Args: @@ -47,7 +48,7 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An Yields: 流式输出的数据块 """ - pass + raise NotImplementedError @staticmethod def normalize_requested_model(model: Optional[str]) -> Optional[str]: @@ -108,23 +109,28 @@ def get_runtime_capabilities(self) -> dict[str, Any]: if cancel_supported else ["unsupported"] ), - "Reason": "" - if cancel_supported - else "runner does not implement cooperative cancellation", + "Reason": ( + "" if cancel_supported else "runner does not implement cooperative cancellation" + ), }, "Checkpoint": checkpoint, "ResumeRun": { "Supported": checkpoint_supported, "ResumeMode": str(checkpoint.get("ResumeMode") or "none"), - "Reason": checkpoint_reason - if not checkpoint_supported - else str(checkpoint.get("ResumeReason") or ""), + "Reason": ( + checkpoint_reason + if not checkpoint_supported + else str(checkpoint.get("ResumeReason") or "") + ), }, "SessionContinuity": { "Supported": True, "Type": "semantic_replay", "Level": "semantic", - "Reason": "conversation transcript can be replayed, but exact runtime checkpoint resume is not available", + "Reason": ( + "conversation transcript can be replayed, but exact runtime checkpoint " + "resume is not available" + ), }, } @@ -193,15 +199,15 @@ def _message_usage(cls, message: Any) -> dict[str, Any]: if isinstance(usage_metadata, Mapping): input_tokens = cls._int_usage_value(usage_metadata.get("input_tokens")) output_tokens = cls._int_usage_value(usage_metadata.get("output_tokens")) - input_token_details = usage_metadata.get("input_token_details") - output_token_details = usage_metadata.get("output_token_details") - if not any( - key in usage_metadata - for key in ("input_tokens", "output_tokens", "total_tokens") - ) and not ( - isinstance(input_token_details, Mapping) and input_token_details - ) and not ( - isinstance(output_token_details, Mapping) and output_token_details + usage_input_details = usage_metadata.get("input_token_details") + usage_output_details = usage_metadata.get("output_token_details") + if ( + not any( + key in usage_metadata + for key in ("input_tokens", "output_tokens", "total_tokens") + ) + and not (isinstance(usage_input_details, Mapping) and usage_input_details) + and not (isinstance(usage_output_details, Mapping) and usage_output_details) ): return {} return { @@ -211,10 +217,10 @@ def _message_usage(cls, message: Any) -> dict[str, Any]: usage_metadata.get("total_tokens") or (input_tokens + output_tokens) ), "input_token_details": ( - dict(input_token_details) if isinstance(input_token_details, Mapping) else {} + dict(usage_input_details) if isinstance(usage_input_details, Mapping) else {} ), "output_token_details": ( - dict(output_token_details) if isinstance(output_token_details, Mapping) else {} + dict(usage_output_details) if isinstance(usage_output_details, Mapping) else {} ), } @@ -237,10 +243,14 @@ def _message_usage(cls, message: Any) -> dict[str, Any]: reasoning_tokens = completion_details.get("reasoning_tokens") if reasoning_tokens is not None: output_token_details["reasoning"] = cls._int_usage_value(reasoning_tokens) - if not any( - key in token_usage - for key in ("prompt_tokens", "completion_tokens", "total_tokens") - ) and not input_token_details and not output_token_details: + if ( + not any( + key in token_usage + for key in ("prompt_tokens", "completion_tokens", "total_tokens") + ) + and not input_token_details + and not output_token_details + ): return {} return { "input_tokens": input_tokens, @@ -295,11 +305,18 @@ def _extract_last_usage(cls, result: Any) -> dict[str, Any]: usage = cls._message_usage(result) return usage if usage else {} - def run_server(self, port: int = 8000) -> None: - """启动 HTTP Server""" - from ksadk.server import app, set_runner + """启动 HTTP Server(per-app factory,不再依赖 set_runner 全局态)。""" import uvicorn - set_runner(self) + from ksadk.agui.config import default_agui_config + 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。 + app = create_runtime_app( + RuntimeAppConfig(runner=self, agui=default_agui_config(self)), + 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 new file mode 100644 index 00000000..a07a2021 --- /dev/null +++ b/ksadk/runners/codex_runner.py @@ -0,0 +1,132 @@ +"""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/deepagents_runner.py b/ksadk/runners/deepagents_runner.py index 451e21ac..7556e9c3 100644 --- a/ksadk/runners/deepagents_runner.py +++ b/ksadk/runners/deepagents_runner.py @@ -10,4 +10,3 @@ class DeepAgentsRunner(LangGraphRunner): """DeepAgents 运行时(复用 LangGraphRunner)""" - diff --git a/ksadk/runners/factory.py b/ksadk/runners/factory.py index 963c524d..5d5aa6d5 100644 --- a/ksadk/runners/factory.py +++ b/ksadk/runners/factory.py @@ -6,6 +6,7 @@ import sys from pathlib import Path from typing import TYPE_CHECKING + from ksadk.detection import DetectionResult, FrameworkType from ksadk.runners.base_runner import BaseRunner @@ -15,11 +16,11 @@ def create_runner(detection_result: DetectionResult, project_dir: str) -> BaseRunner: """根据检测结果创建对应的 Runner - + Args: detection_result: 框架检测结果 project_dir: 项目目录 - + Returns: 对应框架的 Runner 实例 """ @@ -55,19 +56,30 @@ def create_runner(detection_result: DetectionResult, project_dir: str) -> BaseRu if detection_result.type == FrameworkType.ADK: from ksadk.runners.adk_runner import ADKRunner + return ADKRunner(detection_result, project_dir) - + elif detection_result.type == FrameworkType.LANGGRAPH: from ksadk.runners.langgraph_runner import LangGraphRunner + return LangGraphRunner(detection_result, project_dir) - + elif detection_result.type == FrameworkType.LANGCHAIN: + # 新 LangChain(create_agent)= LangGraph 图,经 LangChainRunner 薄壳复用 + # LangGraphRunner(deepagents 同款);legacy 链在 load_agent 时被薄壳拒绝并给迁移指引。 from ksadk.runners.langchain_runner import LangChainRunner + return LangChainRunner(detection_result, project_dir) elif detection_result.type == FrameworkType.DEEPAGENTS: from ksadk.runners.deepagents_runner import DeepAgentsRunner + 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) + else: raise ValueError(f"不支持的框架类型: {detection_result.type}") diff --git a/ksadk/runners/langchain_runner.py b/ksadk/runners/langchain_runner.py index 7d8f7e06..91023fc4 100644 --- a/ksadk/runners/langchain_runner.py +++ b/ksadk/runners/langchain_runner.py @@ -1,751 +1,38 @@ -"""LangChain runner with session continuity aware input preparation.""" +"""LangChainRunner - LangChain 框架运行时(复用 LangGraph 基座,deepagents 同款薄壳)。 + +新 LangChain(``langchain.agents.create_agent``)返回的就是 LangGraph +``CompiledStateGraph``,因此运行时复用 :class:`LangGraphRunner`,与 +``DeepAgentsRunner`` 同款薄壳模式,保持原生能力(工具调用 / 人工审批 HITL / +checkpoint / cancel)和行为一致。 + +**legacy LangChain 不再支持运行**:``AgentExecutor`` / ``LLMChain`` / LCEL 链 +(``prompt | llm | parser``)只有 ``invoke``、没有 ``get_state``/checkpoint,不是 +LangGraph 图。本 runner 在 ``load_agent`` 时识别并拒绝,给出迁移指引,而不是等 +``stream()`` 时才崩一个莫名其妙的 ``AttributeError``。 +""" from __future__ import annotations -import inspect -import logging -import os -import uuid -from typing import Any, AsyncIterator, Dict, Optional +from ksadk.runners.langgraph_runner import LangGraphRunner -from ksadk.runners.base_runner import BaseRunner -from ksadk.runners.utils import ( - get_langfuse_callbacks, - get_langfuse_metadata, - load_agent_module, - prepare_trace_metadata, +_LEGACY_LANGCHAIN_ERROR = ( + "legacy LangChain(AgentExecutor / LLMChain / LCEL 链 `prompt | llm | parser`)" + "不再支持运行。\n" + "请迁移到 LangGraph 基座:\n" + " • 用 `langchain.agents.create_agent(...)` 定义 agent" + "(产出 LangGraph 图,自带工具调用 / 人工审批 HITL / checkpoint),或\n" + " • 直接用 `langgraph.graph.StateGraph` 编排。\n" + "迁移后 detector 会识别为 LANGCHAIN/LANGGRAPH,复用 LangGraphRunner 运行。" ) -from ksadk.sessions.continuity import LangChainSessionAdapter - -logger = logging.getLogger(__name__) -class LangChainRunner(BaseRunner): - """LangChain framework runner.""" +class LangChainRunner(LangGraphRunner): + """LangChain 运行时(复用 :class:`LangGraphRunner`)。""" def load_agent(self) -> None: - self._load_agent(force_reload=False) - - def _load_agent(self, *, force_reload: bool) -> None: - self._agent, self._module = load_agent_module( - self.project_dir, - self.detection_result.entry_point, - self.detection_result.agent_variable, - force_reload=force_reload, - ) - self._loaded_model_name = self.normalize_requested_model( - os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") - ) - - def prepare_for_request(self, model: Optional[str]) -> None: - normalized = self.sync_process_model_env(model) - if normalized is None or self._agent is None: - return - if normalized == getattr(self, "_loaded_model_name", None): - return - self._load_agent(force_reload=True) - - def get_session_adapter(self): - return LangChainSessionAdapter() - - def _get_config(self, session_id: Optional[str] = None) -> Optional[dict[str, Any]]: - config: dict[str, Any] = {} - - langfuse_callbacks = get_langfuse_callbacks() - if langfuse_callbacks: - config["callbacks"] = langfuse_callbacks - - metadata = get_langfuse_metadata(session_id) - user_id, tags, _, _ = prepare_trace_metadata(session_id) - if user_id: - metadata["langfuse_user_id"] = user_id - if tags: - metadata["langfuse_tags"] = tags - config["metadata"] = metadata - - return config or None - - async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: - session_id = input_data.get("session_id") or str(uuid.uuid4())[:8] - path = self._resolve_request_path() - config = self._get_config(session_id) - native_context = self.build_native_context(input_data.get("platform_context")) - - if path == "standard_hook": - payload = self._prepare_with_standard_hook(input_data, session_id) - result = await self._invoke_agent(payload, config=config, context=native_context) - elif path == "runnable_with_message_history": - result = await self._invoke_with_message_history( - input_data, - session_id, - config=config, - context=native_context, - ) - else: - payload = self._prepare_with_replay(input_data) - result = await self._invoke_agent(payload, config=config, context=native_context) - - output = {"output": self._extract_output(result)} - usage = self._extract_usage(result) - if usage: - output["usage"] = usage - last_usage = self._extract_last_usage(result) - if last_usage: - output.setdefault("metadata", {})["last_usage"] = last_usage - return output - - async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: - session_id = input_data.get("session_id") or str(uuid.uuid4())[:8] - path = self._resolve_request_path() - config = self._get_config(session_id) - native_context = self.build_native_context(input_data.get("platform_context")) - - if path == "standard_hook": - payload = self._prepare_with_standard_hook(input_data, session_id) - elif path == "runnable_with_message_history": - payload = {"input": self._prepare_message_history_input(input_data)} - config = self._with_session_config(config, session_id) - else: - payload = self._prepare_with_replay(input_data) - - accumulated_text = "" - last_chunk: Any = None - final_output_text = "" - emitted_non_text_event = False - message_snapshots: dict[str, str] = {} - - try: - if self._should_stream_events(payload): - kwargs = self._build_optional_call_kwargs( - self._agent.astream_events, - config=config, - context=native_context, - ) - kwargs["version"] = "v2" - async for event in self._agent.astream_events(payload, **kwargs): - if not isinstance(event, dict): - continue - event_kind = event.get("event", "") - data = event.get("data") or {} - - if event_kind == "on_chat_model_stream": - chunk = data.get("chunk") if isinstance(data, dict) else None - if chunk is None: - continue - last_chunk = chunk - delta, chunk_type = self._extract_chunk(chunk) - if delta: - if chunk_type == "text": - accumulated_text += delta - else: - emitted_non_text_event = True - yield {"delta": delta, "type": chunk_type} - elif event_kind == "on_tool_start": - emitted_non_text_event = True - yield { - "type": "tool_call", - "tool_name": event.get("name", "unknown"), - "tool_args": data.get("input", {}) if isinstance(data, dict) else {}, - "run_id": event.get("run_id"), - } - elif event_kind == "on_tool_end": - emitted_non_text_event = True - tool_output = data.get("output", "") if isinstance(data, dict) else "" - yield { - "type": "tool_result", - "tool_name": event.get("name", "unknown"), - "tool_args": data.get("input", {}) if isinstance(data, dict) else {}, - "tool_output": ( - tool_output - if isinstance(tool_output, dict) - else (str(tool_output) if tool_output else "") - ), - "run_id": event.get("run_id"), - } - elif event_kind == "on_chain_end": - output = data.get("output") if isinstance(data, dict) else None - extracted_output = self._extract_recognized_output(output) - if extracted_output: - final_output_text = extracted_output - if self._extract_usage(output): - last_chunk = output - elif hasattr(self._agent, "astream"): - kwargs = self._build_optional_call_kwargs( - self._agent.astream, - config=config, - context=native_context, - ) - async for chunk in self._agent.astream(payload, **kwargs): - last_chunk = chunk - message_state = self._extract_message_state(chunk) - if message_state: - content, message_key = message_state - previous = message_snapshots.get(message_key, "") - delta = self._snapshot_delta(content, previous) - message_snapshots[message_key] = content - chunk_type = "text" - else: - delta, chunk_type = self._extract_chunk(chunk) - if delta: - accumulated_text += delta - yield {"delta": delta, "type": chunk_type} - elif hasattr(self._agent, "stream"): - kwargs = self._build_optional_call_kwargs( - self._agent.stream, - config=config, - context=native_context, - ) - for chunk in self._agent.stream(payload, **kwargs): - last_chunk = chunk - message_state = self._extract_message_state(chunk) - if message_state: - content, message_key = message_state - previous = message_snapshots.get(message_key, "") - delta = self._snapshot_delta(content, previous) - message_snapshots[message_key] = content - chunk_type = "text" - else: - delta, chunk_type = self._extract_chunk(chunk) - if delta: - accumulated_text += delta - yield {"delta": delta, "type": chunk_type} - except Exception as exc: - logger.warning("LangChain stream failed: %s", exc) - - if not accumulated_text: - if final_output_text or emitted_non_text_event: - final_chunk = {"output": final_output_text, "type": "final"} - usage = self._extract_usage(last_chunk) - if usage: - final_chunk["usage"] = usage - last_usage = self._extract_last_usage(last_chunk) - if last_usage: - final_chunk.setdefault("metadata", {})["last_usage"] = last_usage - yield final_chunk - return - result = await self.invoke(input_data) - final_chunk = {"output": result.get("output", ""), "type": "final"} - usage = self._extract_usage(result) - if usage: - final_chunk["usage"] = usage - last_usage = self._extract_last_usage(result) - if last_usage: - final_chunk.setdefault("metadata", {})["last_usage"] = last_usage - yield final_chunk - return - - final_chunk = {"output": accumulated_text, "type": "final"} - usage = self._extract_usage(last_chunk) - if usage: - final_chunk["usage"] = usage - last_usage = self._extract_last_usage(last_chunk) - if last_usage: - final_chunk.setdefault("metadata", {})["last_usage"] = last_usage - yield final_chunk - - def _should_stream_events(self, payload: dict[str, Any]) -> bool: - """Use LangGraph events for LangChain create_agent message-state agents.""" - return isinstance(payload.get("messages"), list) and hasattr( - self._agent, "astream_events" - ) - - def _resolve_request_path(self) -> str: - module = getattr(self, "_module", None) - if callable(getattr(module, "ksadk_prepare_input", None)): - return "standard_hook" - if self._is_runnable_with_message_history(): - return "runnable_with_message_history" - return "replay" - - def _prepare_with_standard_hook( - self, input_data: Dict[str, Any], session_id: str - ) -> dict[str, Any]: - module = getattr(self, "_module", None) - prepare_input = getattr(module, "ksadk_prepare_input", None) - if not callable(prepare_input): - return {"input": input_data.get("input", "")} - - payload = {"input": input_data.get("input", "")} - session_context = { - "session_id": session_id, - "history": list(input_data.get("history") or []), - "input_parts": list(input_data.get("input_parts") or []), - "attachments": list(input_data.get("attachments") or []), - "attachment_results": list(input_data.get("attachment_results") or []), - "instructions": input_data.get("instructions"), - "platform_context": input_data.get("platform_context"), - "kb_context": input_data.get("kb_context"), - "memory_context": input_data.get("memory_context"), - } - builtin_context = self._ksadk_builtin_tool_context() - if builtin_context: - session_context.update(builtin_context) - prepared = prepare_input(payload, session_context) - return prepared if isinstance(prepared, dict) else payload - - @staticmethod - def _ksadk_builtin_tool_context() -> dict[str, Any]: - try: - from ksadk.toolsets import ( - builtin_tool_descriptors_for_runtime, - builtin_tools_mode, - builtin_tools_profile, - ) - - mode = builtin_tools_mode(default="off") - descriptors = builtin_tool_descriptors_for_runtime(mode=mode) - if not descriptors: - return {} - return { - "ksadk_tools": descriptors, - "ksadk_builtin_tools_mode": mode, - "ksadk_builtin_tools_profile": builtin_tools_profile("default"), - "deferred_direct_injection_supported": False, - } - except Exception as exc: - logger.warning("Failed to prepare ksadk built-in tool descriptors: %s", exc) - return {} - - def _prepare_with_replay(self, input_data: Dict[str, Any]) -> dict[str, Any]: - user_input = str(input_data.get("input", "") or "") - history = list(input_data.get("history") or []) - ambient_text = self._ambient_context_text(input_data) - instructions = str(input_data.get("instructions") or "").strip() - if not history and not ambient_text and not instructions: - return {"input": user_input} - return { - "input": self._format_replay_prompt( - user_input, - history, - ambient_text=ambient_text, - instructions=instructions, - ) - } - - @staticmethod - def _ambient_context_text(input_data: Dict[str, Any]) -> str: - sections: list[str] = [] - kb_context = input_data.get("kb_context") or {} - kb_text = ( - str(kb_context.get("formatted_text") or "").strip() - if isinstance(kb_context, dict) - else "" - ) - if kb_text: - sections.append(f"Knowledge base context:\n{kb_text}") - - memory_context = input_data.get("memory_context") or {} - memory_text = ( - str(memory_context.get("formatted_text") or "").strip() - if isinstance(memory_context, dict) - else "" - ) - if memory_text: - sections.append(f"Long-term memory context:\n{memory_text}") - - return "\n\n".join(section for section in sections if section) - - def _prepare_message_history_input(self, input_data: Dict[str, Any]) -> str: - user_input = str(input_data.get("input", "") or "") - context_text = self._request_context_text(input_data) - if not context_text: - return user_input - current_input = user_input.strip() or "[empty message]" - return f"{context_text}\n\nCurrent user input:\n{current_input}" - - def _request_context_text(self, input_data: Dict[str, Any]) -> str: - ambient_text = self._ambient_context_text(input_data) - instructions = str(input_data.get("instructions") or "").strip() - return "\n\n".join(section for section in (instructions, ambient_text) if section) - - def _format_replay_prompt( - self, - user_input: str, - history: list[dict[str, Any]], - *, - ambient_text: str = "", - instructions: str = "", - ) -> str: - lines: list[str] = [] - if instructions: - lines.append(instructions) - if ambient_text: - lines.append(ambient_text) - if history: - lines.append("Conversation history:") - normalized_history: list[tuple[str, str]] = [] - for item in history: - role = self._normalize_history_role(item.get("role")) - content = str(item.get("content", "") or "").strip() - if not role or not content: - continue - normalized_history.append((role, content)) - lines.append(f"{role}: {content}") - - if user_input.strip(): - if not normalized_history or normalized_history[-1] != ("user", user_input.strip()): - lines.append(f"user: {user_input.strip()}") - elif ambient_text and not history: - lines.append("Current user input:\n[empty message]") - - return "\n".join(lines) - - @staticmethod - def _normalize_history_role(role: Any) -> str: - normalized = str(role or "").strip().lower() - if normalized in {"assistant", "model"}: - return "assistant" - if normalized == "user": - return "user" - return normalized - - @staticmethod - def _with_session_config( - config: Optional[dict[str, Any]], - session_id: str, - ) -> dict[str, Any]: - merged = dict(config or {}) - configurable = dict(merged.get("configurable") or {}) - configurable["session_id"] = session_id - merged["configurable"] = configurable - return merged - - async def _invoke_agent( - self, - payload: Any, - *, - config: Optional[dict[str, Any]], - context: Optional[dict[str, Any]], - ) -> Any: - if hasattr(self._agent, "ainvoke"): - kwargs = self._build_optional_call_kwargs( - self._agent.ainvoke, - config=config, - context=context, - ) - return await self._agent.ainvoke(payload, **kwargs) - if hasattr(self._agent, "invoke"): - kwargs = self._build_optional_call_kwargs( - self._agent.invoke, - config=config, - context=context, - ) - return self._agent.invoke(payload, **kwargs) - if callable(self._agent): - return self._agent(payload) - raise TypeError("Agent 不支持 invoke 调用") - - def _is_runnable_with_message_history(self) -> bool: - try: - from langchain_core.runnables.history import RunnableWithMessageHistory - - return isinstance(self._agent, RunnableWithMessageHistory) - except Exception: - return False - - async def _invoke_with_message_history( - self, - input_data: Dict[str, Any], - session_id: str, - *, - config: Optional[dict[str, Any]], - context: Optional[dict[str, Any]], - ) -> Any: - context_text = self._request_context_text(input_data) - payload = {"input": self._prepare_message_history_input(input_data)} - session_config = self._with_session_config(config, session_id) - wrapped_runnable = self._extract_wrapped_history_runnable() - message_history = self._get_message_history_store(session_id) - - if context_text and wrapped_runnable is not None and message_history is not None: - return await self._invoke_wrapped_history_with_ambient_context( - input_data=input_data, - wrapped_runnable=wrapped_runnable, - message_history=message_history, - session_config=session_config, - context=context, - ambient_text=context_text, - ) - - try: - return await self._invoke_agent(payload, config=session_config, context=context) - except Exception: - if wrapped_runnable is None or message_history is None: - raise - return await self._invoke_wrapped_history_with_ambient_context( - input_data=input_data, - wrapped_runnable=wrapped_runnable, - message_history=message_history, - session_config=session_config, - context=context, - ambient_text=context_text, - ) - - async def _invoke_wrapped_runnable( - self, - runnable: Any, - payload: Any, - config: Optional[dict[str, Any]], - context: Optional[dict[str, Any]], - ) -> Any: - if hasattr(runnable, "ainvoke"): - kwargs = self._build_optional_call_kwargs( - runnable.ainvoke, - config=config, - context=context, - ) - return await runnable.ainvoke(payload, **kwargs) - if hasattr(runnable, "invoke"): - kwargs = self._build_optional_call_kwargs( - runnable.invoke, - config=config, - context=context, - ) - return runnable.invoke(payload, **kwargs) - raise TypeError("Wrapped runnable does not support invoke") - - async def _invoke_wrapped_history_with_ambient_context( - self, - *, - input_data: Dict[str, Any], - wrapped_runnable: Any, - message_history: Any, - session_config: Optional[dict[str, Any]], - context: Optional[dict[str, Any]], - ambient_text: str, - ) -> Any: - from langchain_core.messages import AIMessage, HumanMessage, SystemMessage - - user_input = str(input_data.get("input", "") or "") - prompt_messages = list(getattr(message_history, "messages", [])) - if ambient_text: - prompt_messages = [SystemMessage(content=ambient_text), *prompt_messages] - prompt_messages.append(HumanMessage(content=user_input or "[empty message]")) - result = await self._invoke_wrapped_runnable( - wrapped_runnable, - {"input": prompt_messages}, - session_config, - context, - ) - output_text = self._extract_output(result) - await self._append_message_history( - message_history, - [ - HumanMessage(content=user_input or "[empty message]"), - AIMessage(content=output_text), - ], - ) - return result - - def _extract_wrapped_history_runnable(self) -> Any | None: - runnable_lambda = self._get_nested_attr( - self._agent, - ("bound", "bound", "last", "bound"), - ) - if runnable_lambda is None: - logger.debug( - "Unable to inspect RunnableWithMessageHistory wrapper: " - "missing bound.bound.last.bound" - ) - return None - - func = getattr(runnable_lambda, "func", None) - if func is None: - logger.debug( - "Unable to inspect RunnableWithMessageHistory wrapper: missing lambda func" - ) - return None - - try: - closure = inspect.getclosurevars(func).nonlocals - except Exception as exc: - logger.debug( - "Unable to inspect RunnableWithMessageHistory wrapper: %s", - exc, - ) - return None - return closure.get("runnable_async") or closure.get("runnable_sync") - - @staticmethod - def _get_nested_attr(obj: Any, path: tuple[str, ...]) -> Any | None: - current = obj - for name in path: - current = getattr(current, name, None) - if current is None: - return None - return current - - def _get_message_history_store(self, session_id: str) -> Any | None: - get_session_history = getattr(self._agent, "get_session_history", None) - if not callable(get_session_history): - return None - return get_session_history(session_id) - - async def _append_message_history(self, history_store: Any, messages: list[Any]) -> None: - if hasattr(history_store, "aadd_messages"): - await history_store.aadd_messages(messages) - return - if hasattr(history_store, "add_messages"): - history_store.add_messages(messages) - return - for message in messages: - role = getattr(message, "type", "") - content = getattr(message, "content", "") - if role == "human" and hasattr(history_store, "add_user_message"): - history_store.add_user_message(content) - elif role == "ai" and hasattr(history_store, "add_ai_message"): - history_store.add_ai_message(content) - - @staticmethod - def _extract_output(result: Any) -> str: - if isinstance(result, dict): - if "output" in result: - return result["output"] - if "text" in result: - return result["text"] - messages = result.get("messages") - if isinstance(messages, list) and messages: - last = messages[-1] - if isinstance(last, dict): - return str(last.get("content", str(last))) - content = getattr(last, "content", None) - if content is not None: - return str(content) - return str(result) - content = getattr(result, "content", None) - if content is not None: - return str(content) - return str(result) - - @classmethod - def _extract_recognized_output(cls, result: Any) -> str: - if result is None: - return "" - if isinstance(result, str): - return result - - if isinstance(result, dict): - for key in ("output", "text"): - if key not in result: - continue - value = result[key] - if isinstance(value, str): - return value - extracted = cls._extract_recognized_output(value) - if extracted: - return extracted - if value is not None and not isinstance(value, (dict, list, tuple)): - return str(value) - - message_state = cls._extract_message_state(result) - return message_state[0] if message_state else "" - - if isinstance(result, (list, tuple)): - for item in reversed(result): - extracted = cls._extract_recognized_output(item) - if extracted: - return extracted - return "" - - command_update = getattr(result, "update", None) - if isinstance(command_update, dict): - return cls._extract_recognized_output(command_update) - - content = cls._ai_message_content(result) - return content if content is not None else "" - - @classmethod - def _extract_message_state(cls, chunk: Any) -> tuple[str, str] | None: - """Extract the newest AI message from LangGraph values/updates snapshots.""" - - def visit(value: Any, path: tuple[str, ...]) -> tuple[str, str] | None: - if not isinstance(value, dict): - return None - - messages = value.get("messages") - if isinstance(messages, list): - for index in range(len(messages) - 1, -1, -1): - message = messages[index] - content = cls._ai_message_content(message) - if content is None: - continue - message_id = ( - message.get("id") - if isinstance(message, dict) - else getattr(message, "id", None) - ) - fallback_key = "/".join((*path, "messages", str(index))) - return content, str(message_id or fallback_key) - - for key, nested in value.items(): - if key == "messages" or not isinstance(nested, dict): - continue - result = visit(nested, (*path, str(key))) - if result: - return result - return None - - return visit(chunk, ()) - - @staticmethod - def _ai_message_content(message: Any) -> str | None: - if isinstance(message, dict): - role = str(message.get("role") or message.get("type") or "").lower() - if role not in {"ai", "assistant", "model", "aimessage", "aimessagechunk"}: - return None - content = message.get("content") - else: - role = str(getattr(message, "type", "") or "").lower() - class_name = type(message).__name__.lower() - if role not in { - "ai", - "assistant", - "model", - "aimessage", - "aimessagechunk", - } and not class_name.startswith("aimessage"): - return None - content = getattr(message, "content", None) - - if isinstance(content, str): - return content - if not isinstance(content, list): - return None - - parts: list[str] = [] - for part in content: - if isinstance(part, str): - parts.append(part) - elif isinstance(part, dict): - text = part.get("text") - if isinstance(text, str): - parts.append(text) - return "".join(parts) - - @staticmethod - def _snapshot_delta(content: str, previous: str) -> str: - if content.startswith(previous): - return content[len(previous) :] - if previous.startswith(content): - return "" - return content - - def _extract_chunk(self, chunk: Any) -> tuple[Optional[str], Optional[str]]: - if isinstance(chunk, dict): - if "output" in chunk: - return chunk["output"], "text" - if "text" in chunk: - return chunk["text"], "text" - return None, None - - reasoning = None - if hasattr(chunk, "reasoning_content") and chunk.reasoning_content: - reasoning = chunk.reasoning_content - elif hasattr(chunk, "additional_kwargs"): - reasoning = chunk.additional_kwargs.get("reasoning_content") - - if reasoning: - return reasoning, "thinking" - - content = chunk.content if hasattr(chunk, "content") else str(chunk) - return content if content else None, "text" + super().load_agent() + # 复用 LangGraphRunner 的前提:agent 必须是 LangGraph 图(create_agent / + # StateGraph 编译产物,具备 get_state/checkpoint)。legacy 链(LCEL / + # AgentExecutor)只有 invoke、无 get_state —— 在此拒绝,而不是运行时才崩。 + if not hasattr(self._agent, "get_state"): + raise ValueError(_LEGACY_LANGCHAIN_ERROR) diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index c0f2e861..a6c5cc55 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -4,25 +4,26 @@ 直接透传 LangGraph 原生能力,最小化封装 """ +import base64 +import inspect import os -import uuid import re +import uuid from typing import Any, AsyncIterator, Dict, Mapping -import base64 -from pathlib import Path -from ksadk.runners.base_runner import BaseRunner -from ksadk.runners.usage_accumulator import accumulate_usage -from ksadk.sessions.continuity import LangGraphSessionAdapter -from ksadk.runners.utils import get_langfuse_callbacks, get_langfuse_metadata, load_agent_module from langgraph.types import Command + from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes 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.sessions.continuity import LangGraphSessionAdapter class LangGraphRunner(BaseRunner): """LangGraph 框架运行时 - + 透传原生 LangGraph 功能,支持任意 State 格式 """ @@ -40,7 +41,7 @@ def _load_agent(self, *, force_reload: bool) -> None: self._loaded_model_name = self.normalize_requested_model( os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") ) - + if not hasattr(self._agent, "invoke"): raise TypeError("加载的对象不是有效的 LangGraph CompiledGraph") @@ -57,10 +58,10 @@ def get_session_adapter(self): def describe_checkpoint_capability(self) -> dict[str, Any]: agent = getattr(self, "_agent", None) - has_checkpointer = bool( - getattr(agent, "checkpointer", None) or getattr(agent, "_checkpointer", None) - ) - if not has_checkpointer: + checkpointer = getattr(agent, "checkpointer", None) + if checkpointer is None: + checkpointer = getattr(agent, "_checkpointer", None) + if checkpointer is None: return { "Supported": False, "Backend": "none", @@ -70,7 +71,16 @@ def describe_checkpoint_capability(self) -> dict[str, Any]: "Reason": "LangGraph graph has no configured checkpointer", } - backend = str(os.getenv("KSADK_CHECKPOINT_BACKEND") or "").strip().lower() + checkpointer_type = type(checkpointer) + type_name = f"{checkpointer_type.__module__}.{checkpointer_type.__name__}".lower() + if "memory" in type_name or "inmemory" in type_name: + backend = "memory" + elif "sqlite" in type_name: + backend = "sqlite" + elif "postgres" in type_name: + backend = "postgres" + else: + backend = str(os.getenv("KSADK_CHECKPOINT_BACKEND") or "").strip().lower() if backend == "local": backend = "sqlite" if not backend: @@ -87,7 +97,9 @@ def describe_checkpoint_capability(self) -> dict[str, Any]: scope = "pod_local" durable = True shared = False - reason = "SQLite checkpoint is durable for local web debugging but is not shared across pods" + reason = ( + "SQLite checkpoint is durable for local web debugging but is not shared across pods" + ) elif backend in {"memory", "inmemory"}: backend = "memory" scope = "process_local" @@ -109,25 +121,23 @@ def get_runtime_capabilities(self) -> dict[str, Any]: capabilities = super().get_runtime_capabilities() capabilities["SessionContinuity"] = { "Supported": True, - "Type": "checkpoint" - if capabilities["Checkpoint"].get("Supported") - else "semantic_replay", - "Level": "runtime" - if capabilities["Checkpoint"].get("Supported") - else "semantic", + "Type": ( + "checkpoint" if capabilities["Checkpoint"].get("Supported") else "semantic_replay" + ), + "Level": "runtime" if capabilities["Checkpoint"].get("Supported") else "semantic", "Reason": "", } return capabilities def _get_config(self, session_id: str) -> dict: """获取运行配置""" - config = {"configurable": {"thread_id": session_id}} - + 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 @staticmethod @@ -154,7 +164,9 @@ def _apply_checkpoint_resume_config( thread_id = str(checkpoint_ref.get("thread_id") or session_id or "").strip() if not thread_id: - raise ValueError("checkpoint_resume requires session_id or framework_ref.langgraph.thread_id") + raise ValueError( + "checkpoint_resume requires session_id or framework_ref.langgraph.thread_id" + ) next_config = dict(config) configurable = dict(next_config.get("configurable") or {}) @@ -164,6 +176,18 @@ def _apply_checkpoint_resume_config( next_config["configurable"] = configurable return next_config + @staticmethod + def _checkpoint_resume_input( + value: Any, + *, + payload_provided: bool, + interrupt_id: str, + ) -> Command | None: + if not payload_provided: + return None + resume_value = {interrupt_id: value} if interrupt_id else value + return Command(resume=resume_value) + @staticmethod def _checkpoint_ref_from_state(state: Any) -> dict[str, Any]: state_config = None @@ -180,17 +204,15 @@ def _checkpoint_ref_from_state(state: Any) -> dict[str, Any]: checkpoint_id = str(configurable.get("checkpoint_id") or "").strip() if not thread_id or not checkpoint_id: return {} - next_nodes_raw = state.get("next") if isinstance(state, dict) else getattr(state, "next", None) + next_nodes_raw = ( + state.get("next") if isinstance(state, dict) else getattr(state, "next", None) + ) next_nodes: list[str] = [] if isinstance(next_nodes_raw, str): if next_nodes_raw.strip(): next_nodes = [next_nodes_raw.strip()] elif isinstance(next_nodes_raw, (list, tuple, set)): - next_nodes = [ - str(item).strip() - for item in next_nodes_raw - if str(item or "").strip() - ] + next_nodes = [str(item).strip() for item in next_nodes_raw if str(item or "").strip()] return { "langgraph": { "thread_id": thread_id, @@ -273,7 +295,11 @@ def _latest_checkpoint_config(config: dict[str, Any]) -> dict[str, Any]: def _ambient_context_text(payload: Dict[str, Any]) -> str: sections: list[str] = [] kb_context = payload.get("kb_context") or {} - kb_text = str(kb_context.get("formatted_text") or "").strip() if isinstance(kb_context, dict) else "" + kb_text = ( + str(kb_context.get("formatted_text") or "").strip() + if isinstance(kb_context, dict) + else "" + ) if kb_text: sections.append(f"Knowledge base context:\n{kb_text}") @@ -325,6 +351,14 @@ def _prepare_state_with_hook( prepared = prepare_state(dict(normalized_payload), session_context) if not isinstance(prepared, dict): raise TypeError("ksadk_prepare_state(payload, session_context) must return a dict") + # A custom state hook owns application state, but must not accidentally + # erase protocol namespaces that framework middleware consumes. In + # particular CopilotKit reads ``ag-ui`` to decide whether to inject the + # official A2UI tool, and reads ``copilotkit.actions`` for frontend + # actions. Keep these namespaces narrow and transport-owned. + for namespace in ("ag-ui", "copilotkit"): + if namespace in normalized_payload: + prepared.setdefault(namespace, normalized_payload[namespace]) return prepared def _to_state(self, payload: Dict[str, Any], history: list) -> Dict[str, Any]: @@ -338,7 +372,7 @@ def _to_state(self, payload: Dict[str, Any], history: list) -> Dict[str, Any]: if "input" in normalized_payload and "messages" not in normalized_payload: from langchain_core.messages import AIMessage, HumanMessage, SystemMessage - messages = [] + messages: list[Any] = [] if system_text: messages.append(SystemMessage(content=system_text)) for msg in history: @@ -442,11 +476,11 @@ def _build_langgraph_human_content( ) continue - file_uri = attachment.get("file_uri") - if not file_uri: + attachment_uri = attachment.get("file_uri") + if not attachment_uri: continue - raw = read_attachment_uri_bytes(file_uri) + raw = read_attachment_uri_bytes(str(attachment_uri)) if not raw: continue @@ -536,7 +570,7 @@ async def _invoke_from_stream_events(self, input_data: Dict[str, Any]) -> Dict[s async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 LangGraph 图 - + 支持两种输入格式: 1. 简化格式: {"input": "hello"} - 自动转换为 messages 2. 原生格式: {"messages": [...]} 或自定义 State - 直接透传 @@ -549,11 +583,12 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: session_id = payload.pop("session_id", None) or str(uuid.uuid4())[:8] is_resume = payload.pop("resume", False) is_checkpoint_resume = bool(payload.pop("checkpoint_resume", False)) + 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") checkpoint_ref = self._extract_langgraph_checkpoint_ref(payload) history = payload.pop("history", []) native_context = self.build_native_context(payload.get("platform_context")) - normalized_payload = self._strip_platform_context_fields(payload) - config = self._get_config(session_id) if is_checkpoint_resume: config = self._apply_checkpoint_resume_config( @@ -561,24 +596,29 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: session_id=session_id, checkpoint_ref=checkpoint_ref, ) - + # 判断输入格式 / resume if is_checkpoint_resume: - state = None - elif self._has_prepare_state_hook(): - state = self._prepare_state_with_hook(payload, session_id, history, is_resume=is_resume) + state = resume_value elif is_resume: - if "input" in normalized_payload and len(normalized_payload) == 1: - state = normalized_payload["input"] - else: - state = normalized_payload + # ``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) else: state = self._to_state(payload, history) try: if is_checkpoint_resume: result = await self._invoke_graph( - None, + self._checkpoint_resume_input( + state, + payload_provided=resume_payload_provided, + interrupt_id=resume_interrupt_id, + ), config=config, context=native_context, ) @@ -606,7 +646,7 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: if metadata: output["metadata"] = {**(output.get("metadata") or {}), **metadata} return output - + except Exception as e: if "Interrupt" in type(e).__name__: interrupt_info = self._get_interrupt_info(self._agent.get_state(config)) @@ -614,7 +654,11 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: "type": "interrupt", "interrupt_info": interrupt_info, "session_id": session_id, - "output": interrupt_info.get("message", "需要用户确认") if isinstance(interrupt_info, dict) else "需要用户确认", + "output": ( + interrupt_info.get("message", "需要用户确认") + if isinstance(interrupt_info, dict) + else "需要用户确认" + ), } raise @@ -623,16 +667,21 @@ def _extract_output(self, result: Any) -> str: if isinstance(result, dict): # 自定义 output 字段是业务显式出参,优先于内部 messages state。 if "output" in result: - return result["output"] + return str(result["output"]) # LangGraph 示例常用 answer 作为最终业务回答字段。 if "answer" in result: - return result["answer"] + return str(result["answer"]) # 标准 messages 格式 if "messages" in result: messages = result["messages"] if messages: last = messages[-1] - return last.get("content", str(last)) if isinstance(last, dict) else getattr(last, "content", str(last)) + content = ( + last.get("content", str(last)) + if isinstance(last, dict) + else getattr(last, "content", str(last)) + ) + return str(content) return str(result) if result else "" def _get_interrupt_info(self, state) -> dict: @@ -642,12 +691,18 @@ def _get_interrupt_info(self, state) -> dict: if hasattr(task, "interrupts") and task.interrupts: for intr in task.interrupts: if hasattr(intr, "value"): - return intr.value + value = intr.value + info = dict(value) if isinstance(value, Mapping) else {"value": value} + interrupt_id = str(getattr(intr, "id", "") or "") + if interrupt_id: + info.setdefault("approval_request_id", interrupt_id) + return info return {} async def _stream_checkpoint_resume_updates( self, *, + stream_input: Any, config: dict[str, Any], context: dict[str, Any] | None, ) -> AsyncIterator[Dict[str, Any]]: @@ -664,11 +719,17 @@ async def _stream_checkpoint_resume_updates( latest_output: Any = None emitted_update = False - async for update in self._agent.astream(None, **kwargs): + tool_names: dict[str, str] = {} + async for update in self._agent.astream(stream_input, **kwargs): emitted_update = True latest_output = update if isinstance(update, Mapping): for node_name, node_output in update.items(): + for tool_event in self._tool_events_from_graph_update( + node_output, + tool_names=tool_names, + ): + yield tool_event yield { "type": "graph_update", "node": str(node_name), @@ -703,6 +764,76 @@ async def _stream_checkpoint_resume_updates( if metadata: yield {"type": "checkpoint", "metadata": metadata} + @staticmethod + def _tool_events_from_graph_update( + node_output: Any, + *, + tool_names: dict[str, str], + ) -> list[dict[str, Any]]: + """Preserve LangGraph update-mode tool messages as structured runner events.""" + + if isinstance(node_output, Mapping): + messages = node_output.get("messages") + else: + messages = getattr(node_output, "messages", None) + if not isinstance(messages, (list, tuple)): + return [] + + events: list[dict[str, Any]] = [] + for message in messages: + if isinstance(message, Mapping): + tool_calls = message.get("tool_calls") + tool_call_id = message.get("tool_call_id") + message_name = message.get("name") + message_content = message.get("content") + message_status = message.get("status") + else: + tool_calls = getattr(message, "tool_calls", None) + tool_call_id = getattr(message, "tool_call_id", None) + message_name = getattr(message, "name", None) + message_content = getattr(message, "content", None) + message_status = getattr(message, "status", None) + + if isinstance(tool_calls, (list, tuple)): + for tool_call in tool_calls: + if isinstance(tool_call, Mapping): + call_id = str(tool_call.get("id") or tool_call.get("tool_call_id") or "") + name = str(tool_call.get("name") or "tool") + args = tool_call.get("args") + else: + call_id = str( + getattr(tool_call, "id", None) + or getattr(tool_call, "tool_call_id", None) + or "" + ) + name = str(getattr(tool_call, "name", None) or "tool") + args = getattr(tool_call, "args", None) + if call_id: + tool_names[call_id] = name + events.append( + { + "type": "tool_call", + "tool_call_id": call_id or name, + "tool_name": name, + "tool_args": args, + } + ) + + if tool_call_id: + call_id = str(tool_call_id) + name = str(message_name or tool_names.get(call_id) or "tool") + status = str(message_status or "").lower() + events.append( + { + "type": "tool_result", + "tool_call_id": call_id, + "tool_name": name, + "tool_output": message_content, + "error": message_content if status in {"error", "failed"} else None, + } + ) + return events + async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: """流式调用 LangGraph 图""" payload = dict(input_data) @@ -711,10 +842,11 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An history = payload.pop("history", []) is_resume = payload.pop("resume", False) is_checkpoint_resume = bool(payload.pop("checkpoint_resume", False)) + 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") checkpoint_ref = self._extract_langgraph_checkpoint_ref(payload) native_context = self.build_native_context(payload.get("platform_context")) - normalized_payload = self._strip_platform_context_fields(payload) - invoke_payload = dict(payload) invoke_payload["session_id"] = session_id if history: @@ -723,7 +855,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An invoke_payload["resume"] = True if is_checkpoint_resume: invoke_payload["checkpoint_resume"] = True - + invoke_payload["resume_payload_provided"] = resume_payload_provided + invoke_payload["resume_interrupt_id"] = resume_interrupt_id + config = self._get_config(session_id) if is_checkpoint_resume: config = self._apply_checkpoint_resume_config( @@ -733,14 +867,13 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An ) if is_checkpoint_resume: - state = None - elif self._has_prepare_state_hook(): - state = self._prepare_state_with_hook(payload, session_id, history, is_resume=is_resume) + state = resume_value elif is_resume: - if "input" in normalized_payload and len(normalized_payload) == 1: - state = normalized_payload["input"] - else: - state = normalized_payload + # 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) else: state = self._to_state(payload, history) @@ -799,6 +932,11 @@ def latest_model_usage() -> dict[str, Any]: if is_checkpoint_resume and callable(getattr(self._agent, "astream", None)): try: async for chunk in self._stream_checkpoint_resume_updates( + stream_input=self._checkpoint_resume_input( + state, + payload_provided=resume_payload_provided, + interrupt_id=resume_interrupt_id, + ), config=config, context=native_context, ): @@ -826,9 +964,23 @@ def latest_model_usage() -> dict[str, Any]: return try: - stream_input = None if is_checkpoint_resume else (Command(resume=state) if is_resume else state) + stream_input = ( + self._checkpoint_resume_input( + state, + payload_provided=resume_payload_provided, + interrupt_id=resume_interrupt_id, + ) + if is_checkpoint_resume + else (Command(resume=state) if is_resume else state) + ) + # stream_mode 含 "custom" 才会产生 on_custom_stream 事件(custom writer); + # 保留默认 "values" 以兼容既有 on_chain_end/graph_update 消费。 stream_kwargs = {"version": "v2", "config": config} - if native_context and self._callable_accepts_keyword(self._agent.astream_events, "context"): + if self._callable_accepts_keyword(self._agent.astream_events, "stream_mode"): + stream_kwargs["stream_mode"] = ["values", "custom"] + if native_context and self._callable_accepts_keyword( + self._agent.astream_events, "context" + ): stream_kwargs["context"] = native_context async for event in self._agent.astream_events(stream_input, **stream_kwargs): event_kind = event.get("event", "") @@ -855,7 +1007,7 @@ def latest_model_usage() -> dict[str, Any]: reasoning = getattr(chunk, "reasoning_content", None) if not reasoning and hasattr(chunk, "additional_kwargs"): reasoning = chunk.additional_kwargs.get("reasoning_content") - + if reasoning: accumulated_reasoning += reasoning yield {"delta": reasoning, "type": "thinking"} @@ -865,9 +1017,9 @@ def latest_model_usage() -> dict[str, Any]: content = self._filter_tool_tags(chunk.content) if isinstance(content, str): if accumulated_reasoning and content.startswith(accumulated_reasoning): - content = content[len(accumulated_reasoning):] + content = content[len(accumulated_reasoning) :] elif reasoning and content.startswith(reasoning): - content = content[len(reasoning):] + content = content[len(reasoning) :] if content: for part in inline_reasoning_parser.feed(content): if not part.text or not part.text.strip(): @@ -888,6 +1040,59 @@ def latest_model_usage() -> dict[str, Any]: if run_key not in stream_usage_run_keys: record_model_usage(event, last_usage or usage) + elif event_kind == "on_chain_stream": + # node 内 get_stream_writer() 写入的自定义数据,经 stream_mode 含 + # "custom" 时,astream_events 包成 on_chain_stream,chunk 为 + # (mode, value) tuple:("custom", value) 是 writer 透传内容, + # ("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" + ): + continue + data = chunk[1] + if isinstance(data, str): + accumulated_text += data + yield {"delta": data, "type": "text"} + continue + if isinstance(data, Mapping): + custom_type = str(data.get("type") or "text") + if custom_type in ("tool_call", "tool_result"): + # 结构化工具事件:透传完整 payload(tool_name/tool_args/ + # tool_output 等),不计入正文,供 UI 渲染工具卡片。 + out = {"type": custom_type} + out.update({k: v for k, v in data.items() if k != "type"}) + yield out + continue + custom_delta = "" + for key in ("delta", "text", "content", "output", "data"): + value = data.get(key) + if isinstance(value, str) and value: + custom_delta = value + break + if not custom_delta: + continue + replace = bool(data.get("replace")) + if custom_type == "thinking": + accumulated_reasoning = ( + 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} + if replace: + out["replace"] = True + yield out + continue + if data is not None: + accumulated_text += str(data) + yield {"delta": str(data), "type": "text"} + elif event_kind == "on_tool_start": emitted_non_text_event = True yield { @@ -896,23 +1101,35 @@ def latest_model_usage() -> dict[str, Any]: "tool_args": event.get("data", {}).get("input", {}), "run_id": event.get("run_id"), } - + elif event_kind == "on_tool_end": emitted_non_text_event = True tool_output = event.get("data", {}).get("output", "") + # LangGraph returns a ToolMessage here for normal tools. + # Preserve its content instead of serializing the repr, + # otherwise structured output such as A2UI envelopes becomes + # unparsable. Keep the callback run_id below: it is paired + # with the preceding ``on_tool_start`` event on this stream. + normalized_output = getattr(tool_output, "content", tool_output) + if isinstance(tool_output, Mapping) and "content" in tool_output: + normalized_output = tool_output["content"] yield { "type": "tool_result", "tool_name": event.get("name", "unknown"), "tool_args": event.get("data", {}).get("input", {}), - "tool_output": tool_output if isinstance(tool_output, dict) else (str(tool_output) if tool_output else ""), + "tool_output": normalized_output, "run_id": event.get("run_id"), } - + elif event_kind == "on_chain_end": output = event.get("data", {}).get("output", {}) if isinstance(output, dict) and "__interrupt__" in output: emitted_non_text_event = True - yield {"type": "interrupt", "interrupt_info": output["__interrupt__"], "session_id": session_id} + yield { + "type": "interrupt", + "interrupt_info": output["__interrupt__"], + "session_id": session_id, + } return extracted_output = self._extract_output(output) if extracted_output: @@ -922,10 +1139,41 @@ def latest_model_usage() -> dict[str, Any]: except Exception as e: if "Interrupt" in type(e).__name__: - yield {"type": "interrupt", "interrupt_info": self._get_interrupt_info(self._agent.get_state(config)), "session_id": session_id} + yield { + "type": "interrupt", + "interrupt_info": self._get_interrupt_info(self._agent.get_state(config)), + "session_id": session_id, + } return raise + # goal-18(ksadk-web 人机交互):图因审批门(HITL)在流式中静默暂停时, + # 这里把审批详情(action_requests)作为 approval 事件冒出,供 UI 渲染审批卡。 + # 此前流式路径只在 checkpoint 标 resumable,UI 拿不到"该批哪个工具/什么参数/允许哪些决定"。 + # 注:get_state 在部分 agent 上是 async,统一按 awaitable 处理;取不到则跳过,不破坏事件流。 + pending_approval = None + try: + _get_state = getattr(self._agent, "aget_state", None) or getattr( + self._agent, "get_state", None + ) + if _get_state is not None: + _maybe_state = _get_state(config) + if inspect.isawaitable(_maybe_state): + _maybe_state = await _maybe_state + pending_approval = self._get_interrupt_info(_maybe_state) + except Exception: + pending_approval = None + if pending_approval: + yield { + "type": "approval", + "interrupt_info": pending_approval, + "session_id": session_id, + } + metadata = await self._latest_checkpoint_metadata(config) + if metadata: + yield {"type": "checkpoint", "metadata": metadata} + return + for part in inline_reasoning_parser.flush(): if not part.text or not part.text.strip(): continue @@ -940,35 +1188,49 @@ def latest_model_usage() -> dict[str, Any]: if final_output_text: final_chunk = {"output": final_output_text, "type": "final"} usage = accumulated_model_usage() or final_output_usage or latest_stream_usage - last_usage = latest_model_usage() or final_output_last_usage or latest_stream_usage or usage + last_usage = ( + latest_model_usage() or final_output_last_usage or latest_stream_usage or usage + ) if usage: final_chunk["usage"] = usage if last_usage: final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk elif not emitted_non_text_event: - result = await self.invoke( - {**invoke_payload, "_ksadk_force_graph_invoke": True} - ) - final_chunk = {"output": result.get("output", ""), "type": "final"} + result = await self.invoke({**invoke_payload, "_ksadk_force_graph_invoke": True}) + fallback_chunk: dict[str, Any] = { + "output": result.get("output", ""), + "type": "final", + } usage = self._extract_usage(result) if usage: - final_chunk["usage"] = usage + fallback_chunk["usage"] = usage last_usage = self._extract_last_usage(result) if last_usage: - final_chunk.setdefault("metadata", {})["last_usage"] = last_usage - yield final_chunk - metadata = result.get("metadata") if isinstance(result, dict) else None - if isinstance(metadata, dict) and metadata.get("agentengine"): - yield {"type": "checkpoint", "metadata": metadata} + fallback_chunk.setdefault("metadata", {})["last_usage"] = last_usage + yield fallback_chunk + checkpoint_metadata = result.get("metadata") if isinstance(result, dict) else None + if isinstance(checkpoint_metadata, dict) and checkpoint_metadata.get("agentengine"): + yield {"type": "checkpoint", "metadata": checkpoint_metadata} return else: final_chunk = {"output": accumulated_text, "type": "final"} state_usage = await self._latest_state_usage(config) - usage = accumulated_model_usage() or state_usage or final_output_usage or latest_stream_usage + usage = ( + accumulated_model_usage() + or state_usage + or final_output_usage + or latest_stream_usage + ) if usage: final_chunk["usage"] = usage - last_usage = latest_model_usage() or state_usage or final_output_last_usage or latest_stream_usage or usage + last_usage = ( + latest_model_usage() + or state_usage + or final_output_last_usage + or latest_stream_usage + or usage + ) final_chunk.setdefault("metadata", {})["last_usage"] = last_usage yield final_chunk @@ -980,6 +1242,6 @@ def _filter_tool_tags(self, content: str) -> str: """过滤 标签""" if not isinstance(content, str): return content - content = re.sub(r'.*?', '', content, flags=re.DOTALL) - content = re.sub(r'', '', content) + content = re.sub(r".*?", "", content, flags=re.DOTALL) + content = re.sub(r"", "", content) return content diff --git a/ksadk/runners/patch_langchain.py b/ksadk/runners/patch_langchain.py index 87aed9a2..34dd032c 100644 --- a/ksadk/runners/patch_langchain.py +++ b/ksadk/runners/patch_langchain.py @@ -5,12 +5,6 @@ import logging from typing import Any, Mapping, cast -from ksadk.conversations.model_options import ( - model_options_for_chat_completions, - model_options_for_responses, -) -from ksadk.runtime_context import get_current_invocation_context - from langchain_core.messages import ( AIMessageChunk, BaseMessageChunk, @@ -22,6 +16,12 @@ ) from langchain_core.messages.tool import tool_call_chunk +from ksadk.conversations.model_options import ( + model_options_for_chat_completions, + model_options_for_responses, +) +from ksadk.runtime_context import get_current_invocation_context + logger = logging.getLogger(__name__) # Original function reference (to avoid recursion if patched multiple times) @@ -95,22 +95,22 @@ def _patched_convert_delta_to_message_chunk( def _patched_convert_message_to_dict(message, *args, **kwargs): """Patched version to include reasoning_content in outgoing requests.""" from langchain_core.messages import AIMessage - + # Call original function first result = _original_convert_message_to_dict(message, *args, **kwargs) - + # If this is an AIMessage with tool_calls and reasoning_content in additional_kwargs if isinstance(message, AIMessage): - additional_kwargs = getattr(message, 'additional_kwargs', {}) or {} - reasoning = additional_kwargs.get('reasoning_content') - + additional_kwargs = getattr(message, "additional_kwargs", {}) or {} + reasoning = additional_kwargs.get("reasoning_content") + # Only add if we have tool_calls (required by some thinking models) if message.tool_calls and reasoning is not None: - result['reasoning_content'] = reasoning + result["reasoning_content"] = reasoning elif message.tool_calls and reasoning is None: # Force add empty reasoning_content for thinking models - result['reasoning_content'] = '' - + result["reasoning_content"] = "" + return result @@ -168,7 +168,7 @@ def apply_patch(): # Patch 1: Fix receiving responses (capture reasoning_content) _original_convert_delta = base_module._convert_delta_to_message_chunk base_module._convert_delta_to_message_chunk = _patched_convert_delta_to_message_chunk - + # Patch 2: Fix sending requests (include reasoning_content for tool_call messages) _original_convert_message_to_dict = base_module._convert_message_to_dict base_module._convert_message_to_dict = _patched_convert_message_to_dict @@ -177,7 +177,7 @@ def apply_patch(): base_module.BaseChatOpenAI._get_request_payload = _patched_base_get_request_payload _original_chat_get_request_payload = base_module.ChatOpenAI._get_request_payload base_module.ChatOpenAI._get_request_payload = _patched_chat_get_request_payload - + base_module._ksadk_patched = True # logger.info("Applied langchain-openai patch for reasoning_content support") diff --git a/ksadk/runners/remote_runner.py b/ksadk/runners/remote_runner.py index 01e9d0e8..f1fe3fd8 100644 --- a/ksadk/runners/remote_runner.py +++ b/ksadk/runners/remote_runner.py @@ -47,7 +47,9 @@ def __init__( self._responses_text_streamed = False self._responses_reasoning_streamed = False self._observed_model: str | None = None # 流式回包里观察到的真实模型名 - self.available_models: list[dict[str, Any]] | None = None # ListAgentModels 拿到的可选模型列表 + self.available_models: list[dict[str, Any]] | None = ( + None # ListAgentModels 拿到的可选模型列表 + ) @staticmethod def _normalize_api_format(api_format: Optional[str]) -> str: @@ -112,19 +114,45 @@ def _build_responses_input(user_input: Any) -> Any: return str(user_input or "") @staticmethod - def _build_responses_conversation_history(history: Any, current_input: Any) -> list[dict[str, Any]]: - if not isinstance(history, Sequence) or isinstance( - history, (str, bytes, bytearray) - ): + def _build_responses_conversation_history( + history: Any, current_input: Any + ) -> list[dict[str, Any]]: + if not isinstance(history, Sequence) or isinstance(history, (str, bytes, bytearray)): return [] messages: list[dict[str, Any]] = [] + current_output_call_ids: set[str] = set() + if isinstance(current_input, Mapping): + current_items = [current_input] + elif isinstance(current_input, Sequence) and not isinstance( + current_input, (str, bytes, bytearray) + ): + current_items = [item for item in current_input if isinstance(item, Mapping)] + else: + current_items = [] + for item in current_items: + if str(item.get("type") or "").strip().lower() != "function_call_output": + continue + call_id = str(item.get("call_id") or "").strip() + if call_id: + current_output_call_ids.add(call_id) current_text = RemoteRunner._responses_message_text( {"role": "user", "content": current_input} ).strip() for item in history: if not isinstance(item, Mapping): continue + item_type = str(item.get("type") or "").strip().lower() + if item_type in {"function_call", "function_call_output"}: + # Responses tool items are already protocol-shaped. Do not + # reinterpret them as chat messages or they lose call_id. + if ( + item_type == "function_call_output" + and str(item.get("call_id") or "").strip() in current_output_call_ids + ): + continue + messages.append(dict(item)) + continue role = str(item.get("role") or "").strip().lower() if role == "model": role = "assistant" @@ -144,7 +172,9 @@ def _build_responses_conversation_history(history: Any, current_input: Any) -> l return messages @staticmethod - def _responses_conversation_name(input_data: Mapping[str, Any], session_id: Optional[str]) -> str: + def _responses_conversation_name( + input_data: Mapping[str, Any], session_id: Optional[str] + ) -> str: if not session_id: return "" platform_context = input_data.get("platform_context") @@ -181,10 +211,27 @@ def _build_responses_payload( "input": self._build_responses_input(user_input), "stream": stream, } - history_enabled = bool(input_data.get("responses_conversation")) and not previous_response_id + history_enabled = ( + bool(input_data.get("responses_conversation")) + or bool(input_data.get("responses_history")) + ) and not previous_response_id if history_enabled: + structured_history = input_data.get("responses_history") + history_input: Sequence[Any] | None + if isinstance(structured_history, Sequence) and not isinstance( + structured_history, (str, bytes, bytearray) + ): + history_input = structured_history + else: + fallback_history = input_data.get("history") + history_input = ( + fallback_history + if isinstance(fallback_history, Sequence) + and not isinstance(fallback_history, (str, bytes, bytearray)) + else None + ) history = self._build_responses_conversation_history( - input_data.get("history"), + history_input, user_input, ) if history: @@ -193,9 +240,7 @@ def _build_responses_payload( if conversation and not previous_response_id: payload["conversation"] = conversation elif ( - input_data.get("responses_conversation") - and session_id - and not previous_response_id + input_data.get("responses_conversation") and session_id and not previous_response_id ): conversation = self._responses_conversation_name(input_data, str(session_id)) if conversation: @@ -209,9 +254,15 @@ def _build_responses_payload( return payload @staticmethod - def _builtin_response_tool_schemas(input_data: Mapping[str, Any] | None = None) -> list[dict[str, Any]]: + def _builtin_response_tool_schemas( + input_data: Mapping[str, Any] | None = None, + ) -> list[dict[str, Any]]: try: - from ksadk.toolsets import builtin_tool_descriptors_for_runtime, builtin_tools_mode, describe_agentengine_tools + from ksadk.toolsets import ( + builtin_tool_descriptors_for_runtime, + builtin_tools_mode, + describe_agentengine_tools, + ) mode = builtin_tools_mode(default="off") descriptors = builtin_tool_descriptors_for_runtime(mode=mode) @@ -220,7 +271,9 @@ def _builtin_response_tool_schemas(input_data: Mapping[str, Any] | None = None) deferred_names = RemoteRunner._deferred_tool_names(input_data) if deferred_names: try: - direct_descriptors = describe_agentengine_tools(include=deferred_names, profile="coding") + direct_descriptors = describe_agentengine_tools( + include=deferred_names, profile="coding" + ) except Exception: direct_descriptors = [] direct_descriptors = [ @@ -347,7 +400,9 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: usage = self._response_usage_payload(data) if self.api_format == "responses": - result = {"output": self._extract_responses_output_text(data) or str(data)} + result: dict[str, Any] = { + "output": self._extract_responses_output_text(data) or str(data) + } if usage: result["usage"] = usage return result @@ -440,7 +495,9 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An # 兜底:api_format 标成 chat 但 runtime 实发 responses 格式 # 事件(event_name 或 data.type 以 response. 开头)→ 按 responses # 解析,否则 response.reasoning.delta 会被 chat 路径当顶层 text 显示。 - _ev = event_name or (str(data.get("type")) if isinstance(data, Mapping) else "") + _ev = event_name or ( + str(data.get("type")) if isinstance(data, Mapping) else "" + ) if _ev.startswith("response."): async for item in self._iter_responses_stream_events( data, event_name=event_name @@ -592,7 +649,13 @@ async def _iter_responses_output_item( else item.get("args", item.get("input")) ) self._remember_responses_tool(key, item, name, args) - yield {"type": "tool_call", "tool_name": name, "tool_args": args, "status": status, "call_id": key} + yield { + "type": "tool_call", + "tool_name": name, + "tool_args": args, + "status": status, + "call_id": key, + } return if item_type == "function_call_output": @@ -605,7 +668,12 @@ async def _iter_responses_output_item( output = self._stringify_responses_payload( item.get("output") if "output" in item else item.get("result", item.get("content")) ) - yield {"type": "tool_result", "tool_name": name, "tool_output": output, "call_id": call_id or key} + yield { + "type": "tool_result", + "tool_name": name, + "tool_output": output, + "call_id": call_id or key, + } return if item_type == "mcp_approval_request": @@ -687,9 +755,17 @@ async def _iter_responses_stream_events( # ksadk/runtime 特有 tool 事件(对标 hosted UI responses-stream.js:176-186) if event_type in {"response.tool_call", "response.ksadk.stage_tool_call"}: name = str(data.get("name") or data.get("tool_name") or "tool") - args = self._stringify_responses_payload(data.get("args") if "args" in data else data.get("arguments")) + args = self._stringify_responses_payload( + data.get("args") if "args" in data else data.get("arguments") + ) cid = str(data.get("call_id") or data.get("item_id") or data.get("run_id") or "") - yield {"type": "tool_call", "tool_name": name, "tool_args": args, "status": "running", "call_id": cid} + yield { + "type": "tool_call", + "tool_name": name, + "tool_args": args, + "status": "running", + "call_id": cid, + } return if event_type in { "response.tool_result", @@ -697,7 +773,9 @@ async def _iter_responses_stream_events( "response.ksadk.stage_tool_result", }: name = str(data.get("name") or data.get("tool_name") or "tool") - output = self._stringify_responses_payload(data.get("output") if "output" in data else data.get("result")) + output = self._stringify_responses_payload( + data.get("output") if "output" in data else data.get("result") + ) cid = str(data.get("call_id") or data.get("item_id") or data.get("run_id") or "") yield { "type": "tool_result", @@ -712,7 +790,13 @@ async def _iter_responses_stream_events( args = f"{self._responses_tool_args.get(key, '')}{str(data.get('delta') or '')}" if key: self._responses_tool_args[key] = args - yield {"type": "tool_call", "tool_name": name, "tool_args": args, "status": "running", "call_id": key} + yield { + "type": "tool_call", + "tool_name": name, + "tool_args": args, + "status": "running", + "call_id": key, + } return if event_type == "response.function_call_arguments.done": key = str(data.get("item_id") or data.get("call_id") or "") @@ -722,16 +806,27 @@ async def _iter_responses_stream_events( ) if key: self._responses_tool_args[key] = args - yield {"type": "tool_call", "tool_name": name, "tool_args": args, "status": "running", "call_id": key} + yield { + "type": "tool_call", + "tool_name": name, + "tool_args": args, + "status": "running", + "call_id": key, + } return if event_type == "response.completed": - response = data.get("response") if isinstance(data.get("response"), dict) else data - output = response.get("output") if isinstance(response, dict) else None - if isinstance(output, list): + nested_response = data.get("response") + response_data: dict[str, Any] = ( + dict(nested_response) if isinstance(nested_response, dict) else data + ) + completed_output = ( + response_data.get("output") if isinstance(response_data, dict) else None + ) + if isinstance(completed_output, list): replayed_item_keys = set(self._responses_streamed_item_keys) replay_completed_text = not self._responses_text_streamed replay_completed_reasoning = not self._responses_reasoning_streamed - for item in output: + for item in completed_output: if isinstance(item, dict): key = self._responses_item_key(item, {"item": item}) call_id = str(item.get("call_id") or "") @@ -748,13 +843,13 @@ async def _iter_responses_stream_events( yield projected chunk = { "type": "responses_output", - "output": output, - "response_id": response.get("id"), + "output": completed_output, + "response_id": response_data.get("id"), } - usage = response.get("usage") + usage = response_data.get("usage") if isinstance(usage, Mapping): chunk["usage"] = dict(usage) - metadata = response.get("metadata") + metadata = response_data.get("metadata") if isinstance(metadata, Mapping): chunk["metadata"] = dict(metadata) yield chunk @@ -762,9 +857,15 @@ async def _iter_responses_stream_events( # response.content_part.delta:glm 等模型把 reasoning 走这个事件, # partType 含 "reasoning" → thinking(不显示),否则 text(对标 hosted UI)。 if event_type == "response.content_part.delta": - part = data.get("part") if isinstance(data.get("part"), dict) else {} + part_value = data.get("part") + part: dict[str, Any] = dict(part_value) if isinstance(part_value, dict) else {} delta = data.get("delta") - part_type = str(part.get("type") or (delta.get("type") if isinstance(delta, dict) else "") or data.get("content_type") or "") + part_type = str( + part.get("type") + or (delta.get("type") if isinstance(delta, dict) else "") + or data.get("content_type") + or "" + ) text = "" if isinstance(delta, dict): text = str(delta.get("text") or delta.get("content") or "") diff --git a/ksadk/runners/usage_accumulator.py b/ksadk/runners/usage_accumulator.py index fef7df2e..ac3f8218 100644 --- a/ksadk/runners/usage_accumulator.py +++ b/ksadk/runners/usage_accumulator.py @@ -6,6 +6,7 @@ cache_creation),直接相加不重复;input_token_details 键名不统一(cached/cache_read/ cache_creation),逐键求和作诊断明细。 """ + from __future__ import annotations from typing import Any diff --git a/ksadk/runners/utils/langfuse.py b/ksadk/runners/utils/langfuse.py index b4bd5841..58ecd90c 100644 --- a/ksadk/runners/utils/langfuse.py +++ b/ksadk/runners/utils/langfuse.py @@ -157,7 +157,7 @@ def get_langfuse_callbacks() -> list[Any]: return callbacks -def get_langfuse_metadata(session_id: str = None) -> dict: +def get_langfuse_metadata(session_id: str | None = None) -> dict[str, Any]: """获取 Langfuse 的 metadata 字典 通过 metadata 字段传递 trace 属性: @@ -171,7 +171,7 @@ def get_langfuse_metadata(session_id: str = None) -> dict: Returns: 包含 Langfuse 属性的 metadata 字典 """ - metadata = {} + metadata: dict[str, Any] = {} if session_id: metadata["langfuse_session_id"] = session_id @@ -201,7 +201,7 @@ def get_langfuse_metadata(session_id: str = None) -> dict: return metadata -def prepare_trace_metadata(session_id: str = None) -> tuple: +def prepare_trace_metadata(session_id: str | None = None) -> tuple[Any, list[Any], Any, Any]: """准备 Trace 元数据 Returns: diff --git a/ksadk/runners/utils/loader.py b/ksadk/runners/utils/loader.py index 3c13e1af..d70e172d 100644 --- a/ksadk/runners/utils/loader.py +++ b/ksadk/runners/utils/loader.py @@ -18,37 +18,37 @@ def load_agent_module( force_reload: bool = False, ) -> Any: """加载 Agent 模块 - + Args: project_dir: 项目目录 entry_point: 入口文件 (e.g., "agent.py") agent_variable: Agent 变量名 (e.g., "root_agent", "graph") - + Returns: 加载的 Agent 对象 - + Raises: ImportError: 模块导入失败 AttributeError: 未找到 Agent 变量 """ project_path = Path(project_dir).resolve() - + # 添加项目目录到 Python 路径 if str(project_path) not in sys.path: sys.path.insert(0, str(project_path)) src_path = project_path / "src" if src_path.is_dir() and str(src_path) not in sys.path: sys.path.insert(0, str(src_path)) - + # 确定模块名 if entry_point.endswith(".py"): module_name = entry_point[:-3] else: module_name = entry_point - + # 路径转换为模块路径 module_name = module_name.replace("/", ".").replace("\\", ".") - + try: if force_reload and module_name in sys.modules: module = importlib.reload(sys.modules[module_name]) diff --git a/ksadk/runtime/__init__.py b/ksadk/runtime/__init__.py new file mode 100644 index 00000000..133ab2a3 --- /dev/null +++ b/ksadk/runtime/__init__.py @@ -0,0 +1,41 @@ +"""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", +] diff --git a/ksadk/runtime/adapter.py b/ksadk/runtime/adapter.py new file mode 100644 index 00000000..2d33041e --- /dev/null +++ b/ksadk/runtime/adapter.py @@ -0,0 +1,345 @@ +"""RuntimeAdapter 平台接口 (goal-03 / G0.3 冻结稿)。 + +**只做签名与语义冻结,不实现具体 adapter**(具体 adapter 是后续 A4/A6,如 ADK/LangGraph +adapter)。本模块定义三层结构与六动词签名,供 Runtime 产生端、server 持久化、A2A +真实 cancel 等共同消费。 + +结构(H2 §4.2): + +- :class:`BaseRuntime`:表达 Runtime **原生能力**(现有 ``BaseRunner`` 的演进目标,不推倒)。 +- :class:`RuntimeAdapter`:把原生能力映射为**平台接口**(六动词)。 +- :class:`RuntimeRegistry`:按 ``runtime_type`` 注册 adapter(替代 ``runners/factory.py`` + 的 if/elif 分发)。 + +六动词语义契约(2026-07-21 友商代码核验,见 docstring 各处): + +1. ``stream`` 返回 :class:`~ksadk.events.runtime_event.RuntimeEvent` 事件流(对接 G0.2), + 模型 = **事件流 + 独立命令/恢复通道**:审批回包走 ``resume``/``submit``,不在事件流 + 回写(非 duplex stream)——codex/ADK/LangGraph 都是这个模型。 +2. ``cancel`` 是状态机,返回 :class:`CancelResult` 枚举(不是 bool——Wegent 实测 bool + 区分不了"记 pending"和"真 interrupt")。 +3. ``resume`` 拆 :class:`ResumeTarget`(恢复目标)与 :class:`ResumePayload`(回包)两个 + 参数,不混成 union(ADK 区分 invocation_id 目标 vs function response 回包;Codex 区分 + resume_thread_id vs Op 回包)。 +4. ``checkpoint`` 粒度用 :class:`CheckpointCapability` 声明(不承诺所有 runtime 同等能力, + 诚实暴露 capability matrix)。 +5. ``start`` 带 session/tenant 维度(:class:`StartRequest` 的 ``user_id`` + ``session_id``), + 不只是 prompt。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, AsyncIterator, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from ksadk.events.runtime_event import RuntimeEvent + +# --------------------------------------------------------------------------- +# cancel 状态机 +# --------------------------------------------------------------------------- + + +class CancelResult(str, Enum): + """cancel 的结果状态机。 + + Wegent ``cancel()`` 实测:无活跃 turn 记 pending 也返回 true、成功 interrupt 也返回 + true,**bool 区分不了两种结果**——因此必须用枚举显式区分。 + """ + + INTERRUPTED_ACTIVE_TURN = "interrupted_active_turn" + """有活跃 turn,已真实 interrupt。""" + + PENDING_CANCEL_RECORDED = "pending_cancel_recorded" + """无活跃 turn,已记录 pending cancel,下一个 turn 开始时被消费。""" + + NOT_RUNNING = "not_running" + """目标不在运行(无可取消的活跃/pending run)。""" + + FAILED = "failed" + """取消动作本身失败(如底层 runtime 报错)。""" + + +# --------------------------------------------------------------------------- +# resume 目标与回包(两件拆开的的事) +# --------------------------------------------------------------------------- + + +class ResumeTarget(BaseModel): + """恢复目标:回到哪里。 + + - ``invocation_id``:精确到某次调用(ADK forward-only resume)。 + - ``thread_id``:某条会话线程(Codex resume_thread_id)。 + - ``checkpoint_id``:某个 checkpoint(LangGraph time-travel)。 + """ + + kind: Literal["invocation_id", "thread_id", "checkpoint_id"] + id: str + + +class ResumePayload(BaseModel): + """恢复回包:带着什么输入恢复(可为空——纯粹续跑)。 + + - ``tool_result``:工具执行结果(function response)。 + - ``approval_decision``:审批决定(对应 ``approval.requested`` 的回包,走命令通道)。 + - ``hitl_answer``:human-in-the-loop 回答。 + - ``free_text``:自由文本输入。 + """ + + kind: Literal["tool_result", "approval_decision", "hitl_answer", "free_text"] + call_id: Optional[str] = None + """对应的 ``approval.requested`` / ``tool.call`` 的 id(如有)。""" + data: Any = None + + +# --------------------------------------------------------------------------- +# checkpoint 能力声明 +# --------------------------------------------------------------------------- + + +class CheckpointCapability(BaseModel): + """checkpoint 粒度声明。诚实暴露,不承诺所有 runtime 同等能力。 + + 与现有 ``BaseRunner.describe_checkpoint_capability`` 对齐演进。 + """ + + supported: bool + granularity: Literal["delta", "snapshot", "none"] + """delta(增量)vs snapshot(快照)vs none。""" + rollback_scope: Literal["turn", "invocation", "none"] + """可按 turn 还是 invocation 回滚。""" + fork_supported: bool + """是否支持从某 checkpoint fork 出新分支。""" + durable: bool + """是否持久化(跨进程/重启保留)。""" + shared_across_pods: bool + """是否跨 pod 共享(K8s 多副本可读同一 checkpoint)。""" + reason: str = "" + + +class CheckpointDescriptor(BaseModel): + """一次 checkpoint 的描述(checkpoint 动词的返回值)。""" + + checkpoint_id: str + invocation_id: str + capability: CheckpointCapability + ref: dict[str, Any] = Field(default_factory=dict) + """runtime 私有引用(如 LangGraph checkpoint 的 (thread_id, checkpoint_id) 元组)。""" + + +# --------------------------------------------------------------------------- +# start 请求与 run 句柄 +# --------------------------------------------------------------------------- + + +CONVERSATION_PREPROCESSING_METADATA_KEY = "conversation_request" +"""StartRequest metadata key for the shared conversation preprocessing contract.""" + + +class ConversationPreprocessingRequest(BaseModel): + """Transport-neutral input for the existing conversation preprocessing path. + + Protocol adapters keep their wire-specific fields out of the runner payload and + place the canonical conversation inputs here. ``RunnerRuntimeAdapter`` then + reuses the same history, attachment, model-policy, platform-context, ambient + KB/memory and tracing preparation as the normal RunAgent/Responses entrypoints. + + The model is additive and allows unknown fields so a newer protocol adapter can + talk to an older runtime without losing forward-compatible metadata. + """ + + model_config = ConfigDict(extra="allow") + + messages: list[dict[str, Any]] = Field(default_factory=list) + model_metadata: dict[str, Any] = Field(default_factory=dict) + model_options: dict[str, Any] = Field(default_factory=dict) + state_delta: dict[str, Any] = Field(default_factory=dict) + instructions: Optional[str] = None + request_metadata: dict[str, Any] = Field(default_factory=dict) + custom_metadata: dict[str, Any] = Field(default_factory=dict) + account_id: Optional[str] = None + response_id: Optional[str] = None + + +class StartRequest(BaseModel): + """start 的输入:带 session/tenant 维度,不只是 prompt。""" + + input: Any + """用户消息或结构化输入。""" + user_id: str + session_id: str + agent_id: Optional[str] = None + model: Optional[str] = None + config: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + + def conversation_preprocessing(self) -> Optional[ConversationPreprocessingRequest]: + """Return the opt-in shared preprocessing request, if one was supplied.""" + + raw = self.metadata.get(CONVERSATION_PREPROCESSING_METADATA_KEY) + if raw is None: + return None + return ConversationPreprocessingRequest.model_validate(raw) + + +class RunHandle(BaseModel): + """一次 run 的不透明句柄(start 返回,后续 stream/cancel/resume/checkpoint/close 用)。""" + + run_id: str + """= invocation_id。""" + session_id: str + runtime_type: str + native_ref: dict[str, Any] = Field(default_factory=dict) + """adapter 私有引用(如 ADK 的 (app_name, user_id, session_id)、Codex 的 thread_ref)。""" + + +# --------------------------------------------------------------------------- +# BaseRuntime:Runtime 原生能力 +# --------------------------------------------------------------------------- + + +class BaseRuntime(ABC): + """Runtime 原生能力面(现有 ``BaseRunner`` 的演进目标,不推倒)。 + + ``BaseRunner`` 的 ``request_cancel`` / ``describe_checkpoint_capability`` / + ``invoke`` / ``stream`` 是原生能力的现状;``BaseRuntime`` 是其平台化抽象。 + 具体 runner(ADK/LangGraph/...)以 ``BaseRuntime`` 表达原生能力,再由 + :class:`RuntimeAdapter` 映射为平台六动词。 + """ + + runtime_type: str = "unknown" + + @abstractmethod + def native_capabilities(self) -> dict[str, Any]: + """原生能力声明(cancel / checkpoint / resume / session continuity 等)。""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# RuntimeAdapter:平台六动词 +# --------------------------------------------------------------------------- + + +class RuntimeAdapter(ABC): + """把 :class:`BaseRuntime` 的原生能力映射为平台接口(六动词)。 + + 签名冻结:以下六个方法的名字、参数、返回类型在 v1 冻结,只允许 additive 演进 + (新增方法/可选参数),不允许改既有签名。 + """ + + def __init__(self, runtime: BaseRuntime) -> None: + self._runtime = runtime + + @property + def runtime(self) -> BaseRuntime: + return self._runtime + + @abstractmethod + async def start(self, request: StartRequest) -> RunHandle: + """启动一次 run,返回句柄。""" + raise NotImplementedError + + @abstractmethod + def stream(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent]: + """订阅 run 的结构化事件流(对接 G0.2 RuntimeEvent)。 + + 注意:这是**事件流 + 独立命令/恢复通道**模型——审批回包/工具结果走 + :meth:`resume`,不在本事件流上回写(非 duplex stream)。 + """ + raise NotImplementedError + + @abstractmethod + async def cancel(self, handle: RunHandle) -> CancelResult: + """请求取消。返回状态机结果;成功 cancel 级联丢弃该 turn 的 pending 审批。""" + raise NotImplementedError + + @abstractmethod + async def resume( + self, + handle: RunHandle, + target: ResumeTarget, + payload: Optional[ResumePayload], + ) -> RunHandle: + """恢复 run。``target`` 是恢复目标,``payload`` 是回包(可为空)。""" + raise NotImplementedError + + async def attach(self, handle: RunHandle) -> RunHandle: + """Attach a persisted handle in this process before ``resume``/``stream``. + + A handle restored from durable task metadata is not evidence that the + underlying runner exists in this process. Adapters which support + cross-process recovery must implement this seam using their framework's + durable checkpoint/session API. The default deliberately fails closed. + """ + raise RuntimeError( + f"{type(self).__name__} does not support attaching persisted run " + f"{handle.run_id!r}; durable runtime restore is unavailable" + ) + + def is_handle_attached(self, handle: RunHandle) -> bool: + """Return whether ``handle`` is already attached to this adapter process.""" + return False + + @abstractmethod + async def checkpoint(self, handle: RunHandle) -> CheckpointDescriptor: + """在当前位置创建 checkpoint,返回描述(粒度见 capability)。""" + raise NotImplementedError + + @abstractmethod + async def close(self, handle: RunHandle) -> None: + """释放 run 持有的运行期资源。""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# RuntimeRegistry +# --------------------------------------------------------------------------- + + +class RuntimeRegistry: + """按 ``runtime_type`` 注册/查找 :class:`RuntimeAdapter`。 + + 替代 ``runners/factory.py`` 的 if/elif 分发:新 runtime 通过 ``register`` + 注册,不再改 factory 分支。 + """ + + def __init__(self) -> None: + self._adapters: dict[str, type[RuntimeAdapter]] = {} + + def register(self, runtime_type: str, adapter_cls: type[RuntimeAdapter]) -> 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]: + try: + return self._adapters[runtime_type] + except KeyError: + raise KeyError( + f"未注册的 runtime_type: {runtime_type!r}(已注册: {sorted(self._adapters)})" + ) from None + + def create(self, runtime_type: str, runtime: BaseRuntime) -> RuntimeAdapter: + """按 runtime_type 实例化 adapter(注入原生 runtime)。""" + return self.get(runtime_type)(runtime) + + def registered_types(self) -> list[str]: + return sorted(self._adapters) + + +__all__ = [ + "BaseRuntime", + "CancelResult", + "CheckpointCapability", + "CheckpointDescriptor", + "ResumePayload", + "ResumeTarget", + "RunHandle", + "RuntimeAdapter", + "RuntimeRegistry", + "StartRequest", +] diff --git a/ksadk/runtime/framework_adapters.py b/ksadk/runtime/framework_adapters.py new file mode 100644 index 00000000..e31b99f5 --- /dev/null +++ b/ksadk/runtime/framework_adapters.py @@ -0,0 +1,124 @@ +"""ADK / LangGraph 两个框架的 RuntimeAdapter (goal-07)。 + +在通用 :class:`~ksadk.runtime.runner_adapter.RunnerRuntimeAdapter` 之上,只声明 +框架差异(resume 目标 + checkpoint 粒度),诚实暴露,不开接口后门: + +- ADK:**forward-only**,resume 经 ``invocation_id``;不支持 time-travel/fork。 +- LangGraph:**time-travel**,resume 经 ``checkpoint_id``;可按 turn 回滚/fork。 + +并用 :func:`build_default_registry` 注册进 G0.3 ``RuntimeRegistry``。 +""" + +from __future__ import annotations + +from typing import Optional + +from ksadk.runners.base_runner import BaseRunner +from ksadk.runtime.adapter import ( + CheckpointCapability, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeRegistry, +) +from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter + + +class ADKRuntimeAdapter(RunnerRuntimeAdapter): + """ADK 框架 adapter(forward-only resume)。""" + + def __init__(self, runner: BaseRunner) -> None: + super().__init__(runner, runtime_type="adk") + + async def _resume_native( + self, + handle: RunHandle, + target: ResumeTarget, + payload: Optional[ResumePayload], + ) -> Optional[dict]: + # ADK forward-only:恢复目标 = invocation_id。注入 adk_runner 真消费的 + # ``checkpoint_resume`` + ``framework_ref.adk.invocation_id`` → run_async(invocation_id)。 + return { + "checkpoint_resume": True, + "run_id": target.id, + "framework_ref": {"adk": {"invocation_id": target.id}}, + "input": payload.data if payload else None, + } + + def _checkpoint_capability(self) -> CheckpointCapability: + capability = super()._checkpoint_capability() + if not capability.supported: + return capability + return capability.model_copy( + update={ + "granularity": "delta", + "rollback_scope": "invocation", + "fork_supported": False, + } + ) + + +class LangGraphRuntimeAdapter(RunnerRuntimeAdapter): + """LangGraph 框架 adapter(time-travel resume)。""" + + def __init__(self, runner: BaseRunner) -> None: + super().__init__(runner, runtime_type="langgraph") + + async def _resume_native( + self, + handle: RunHandle, + target: ResumeTarget, + payload: Optional[ResumePayload], + ) -> Optional[dict]: + if target.kind != "checkpoint_id": + raise ValueError(f"LangGraph resume requires checkpoint_id target, got {target.kind!r}") + known_checkpoint_ids = { + str(checkpoint_id) + for checkpoint_id in handle.native_ref.get("known_checkpoint_ids", []) + } + if target.id not in known_checkpoint_ids: + raise ValueError(f"checkpoint {target.id!r} does not belong to run {handle.run_id!r}") + if payload is not None and payload.call_id: + pending_approval_ids = { + str(call_id) for call_id in handle.native_ref.get("pending_approval_ids", []) + } + if payload.call_id not in pending_approval_ids: + raise ValueError(f"unknown interrupt {payload.call_id!r}") + # LangGraph time-travel:恢复目标 = checkpoint_id。注入 langgraph_runner 真消费的 + # ``checkpoint_resume`` + ``framework_ref.langgraph.{checkpoint_id,thread_id}``。 + return { + "checkpoint_resume": True, + "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} + }, + "input": payload.data if payload else None, + } + + def _checkpoint_capability(self) -> CheckpointCapability: + capability = super()._checkpoint_capability() + if not capability.supported: + return capability + return capability.model_copy( + update={ + "granularity": "snapshot", + "rollback_scope": "turn", + "fork_supported": True, + } + ) + + +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/preprocessing.py b/ksadk/runtime/preprocessing.py new file mode 100644 index 00000000..18aae7c6 --- /dev/null +++ b/ksadk/runtime/preprocessing.py @@ -0,0 +1,118 @@ +"""Shared conversation preprocessing for RuntimeAdapter-backed transports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from ksadk.conversations.runtime_input import ( + _build_runner_ambient_contexts, + _build_runner_request_payload, + _inject_runner_deferred_tools_for_request, + _runner_type_name, +) +from ksadk.conversations.runtime_preparation import build_run_input +from ksadk.runtime.adapter import ( + CONVERSATION_PREPROCESSING_METADATA_KEY, + StartRequest, +) +from ksadk.runtime_context import PlatformInvocationContext + + +@dataclass +class PreparedRuntimeStart: + """Runner payload plus the ContextVar/tracing identity used to execute it.""" + + runner_input: dict[str, Any] + context: PlatformInvocationContext + input_text: str + response_id: str | None = None + + +def _fallback_messages(input_value: Any) -> list[dict[str, Any]]: + if isinstance(input_value, list) and all(isinstance(item, dict) for item in input_value): + return [dict(item) for item in input_value] + if isinstance(input_value, dict) and isinstance(input_value.get("messages"), list): + return [dict(item) for item in input_value["messages"] if isinstance(item, dict)] + return [{"role": "user", "content": input_value}] + + +async def prepare_runtime_start(request: StartRequest, runner: Any) -> PreparedRuntimeStart | None: + """Prepare an opted-in StartRequest through the canonical conversation pipeline. + + Requests without ``conversation_request`` retain the frozen RuntimeAdapter v1 + behavior. This keeps A2A, Harness and framework contract callers unchanged while + allowing AG-UI and future transports to share the production input semantics. + """ + + conversation = request.conversation_preprocessing() + if conversation is None: + return None + + prepare_for_request = getattr(runner, "prepare_for_request", None) + if callable(prepare_for_request): + prepare_for_request(request.model) + + outer_metadata = { + key: value + for key, value in request.metadata.items() + if key != CONVERSATION_PREPROCESSING_METADATA_KEY + } + 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, + ) + _inject_runner_deferred_tools_for_request(runner, prepared) + ambient_contexts = _build_runner_ambient_contexts( + runner=runner, + user_id=request.user_id, + user_input=prepared.user_input, + ) + runtime_context = PlatformInvocationContext( + agent_id=str(request.agent_id or "agent"), + user_id=request.user_id, + account_id=str(conversation.account_id or ""), + session_id=prepared.session_id, + history=list(prepared.history), + input_content=list(prepared.input_content), + input_messages=list(prepared.input_messages), + input_parts=list(prepared.user_parts), + attachments=list(prepared.attachments), + attachment_results=list(prepared.attachment_results), + current_attachments=list(prepared.current_attachments), + current_attachment_results=list(prepared.current_attachment_results), + has_current_files=prepared.has_current_files, + runner_type=_runner_type_name(runner), + metadata=dict(conversation.custom_metadata), + model=request.model, + model_options=prepared.model_options, + kb_context=ambient_contexts.get("kb_context"), + memory_context=ambient_contexts.get("memory_context"), + ) + canonical_payload = _build_runner_request_payload( + prepared=prepared, + model=request.model, + runtime_context=runtime_context, + runner=runner, + ) + return PreparedRuntimeStart( + runner_input={**request.config, **canonical_payload}, + context=runtime_context, + input_text=prepared.user_input or prepared.user_display_input, + response_id=conversation.response_id, + ) + + +__all__ = ["PreparedRuntimeStart", "prepare_runtime_start"] diff --git a/ksadk/runtime/runner_adapter.py b/ksadk/runtime/runner_adapter.py new file mode 100644 index 00000000..b7e9ecd5 --- /dev/null +++ b/ksadk/runtime/runner_adapter.py @@ -0,0 +1,873 @@ +"""RunnerRuntimeAdapter — 把现有 ``BaseRunner`` 对齐到 G0.3 ``RuntimeAdapter`` (goal-07)。 + +不推倒现有 runner(ADK/LangGraph 的 stream/invoke 已在跑),在它们之上实现平台六动词。 +**cancel 不再是空壳**:实现冻结的 cancel 状态机——活跃 turn interrupt(关闭流)/ +无活跃 turn 记 pending / **级联丢弃 pending 工具审批** / 返回 :class:`CancelResult`。 + +框架差异(resume 目标、checkpoint 粒度)由子类钩子 ``_resume_native`` 与 +``_checkpoint_capability`` 诚实声明(ADK forward-only vs LangGraph time-travel)。 +""" + +from __future__ import annotations + +import asyncio +import copy +import inspect +import json +import logging +from collections.abc import Mapping +from contextlib import nullcontext +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Dict, Optional, cast + +from ksadk.conversations.runtime_input import _runner_name +from ksadk.conversations.runtime_observability import ( + _conversation_span_scope, + _set_conversation_input_attributes, + _set_conversation_output_attributes, + _set_conversation_span_attributes, + _set_conversation_usage_attributes, +) +from ksadk.events.runtime_event import EventType, RuntimeEvent +from ksadk.runners.base_runner import BaseRunner +from ksadk.runtime.adapter import ( + BaseRuntime, + CancelResult, + CheckpointCapability, + CheckpointDescriptor, + ResumePayload, + ResumeTarget, + RunHandle, + RuntimeAdapter, + StartRequest, +) +from ksadk.runtime.preprocessing import PreparedRuntimeStart, prepare_runtime_start +from ksadk.runtime_context import platform_invocation_scope + +logger = logging.getLogger(__name__) + +_STREAM_STOP = object() +_ResumeKey = tuple[str, str, str, str, str] + + +async def _anext_or_stop(gen: AsyncIterator[Any]) -> Any: + """取下一个 chunk;流结束返回 _STREAM_STOP sentinel(便于竞速)。""" + try: + return await gen.__anext__() + except StopAsyncIteration: + return _STREAM_STOP + + +def _a2ui_surface_event(chunk: Any) -> tuple[str, dict[str, Any]] | None: + """Recognize a validated A2UI tool envelope and make it a first-class event. + + The dynamic ``generate_a2ui`` tool returns official v0.9 operations as a + JSON tool result. Tool results are otherwise opaque to the runtime, which + would leave AG-UI with nothing to project until a page reload reconstructs + history. Convert exactly that envelope at the runtime boundary so it is + streamed, persisted, and replayed like every other A2UI surface. + """ + + if not isinstance(chunk, dict): + return None + value = chunk.get("tool_output", chunk.get("output")) + if value is not None and hasattr(value, "content"): + value = value.content + if isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + return None + if not isinstance(value, Mapping): + return None + operations_raw = value.get("a2ui_operations") + if not isinstance(operations_raw, list) or not operations_raw: + return None + operations = [dict(operation) for operation in operations_raw if isinstance(operation, Mapping)] + if not operations: + return None + + known: list[tuple[str, str]] = [] + for operation in operations: + for key, event_type in ( + ("createSurface", EventType.A2UI_SURFACE_BEGIN), + ("updateComponents", EventType.A2UI_SURFACE_UPDATE), + ("updateDataModel", EventType.A2UI_SURFACE_UPDATE), + ("deleteSurface", EventType.A2UI_SURFACE_END), + ): + detail = operation.get(key) + if isinstance(detail, Mapping) and isinstance(detail.get("surfaceId"), str): + surface_id = detail["surfaceId"].strip() + if surface_id: + known.append((surface_id, event_type)) + break + if not known: + return None + surface_ids = {surface_id for surface_id, _event_type in known} + if len(surface_ids) != 1: + logger.warning("ignoring A2UI tool result with multiple surfaces") + return None + surface_id = known[0][0] + event_type = ( + EventType.A2UI_SURFACE_BEGIN + if any(kind == EventType.A2UI_SURFACE_BEGIN for _surface_id, kind in known) + else known[0][1] + ) + return event_type, {"surface_id": surface_id, "operations": operations} + + +def _coerce_literal(value: Any, allowed: tuple[str, ...], default: str) -> Any: + """把 value 收窄到 allowed 之一(不在则回退 default),供 Literal 字段使用。""" + return value if value in allowed else default + + +class _RunnerAsBaseRuntime(BaseRuntime): + """把 ``BaseRunner`` 包装为 G0.3 ``BaseRuntime``(原生能力面)。""" + + def __init__(self, runner: BaseRunner, runtime_type: str) -> None: + self._runner = runner + self.runtime_type = runtime_type + + @property + def runner(self) -> BaseRunner: + return self._runner + + def native_capabilities(self) -> dict[str, Any]: + caps = getattr(self._runner, "get_runtime_capabilities", None) + if callable(caps): + try: + return dict(caps()) + except Exception: # noqa: BLE001 + pass + return {"Framework": self.runtime_type} + + +@dataclass +class _ActiveRun: + """一次进行中的 run(adapter 用于 cancel 追踪)。""" + + invocation_id: str + session_id: str + stream: Optional[AsyncIterator[RuntimeEvent]] = None + pending_approvals: set[str] = field(default_factory=set) + interrupt_event: asyncio.Event = field(default_factory=asyncio.Event) + cancellation_ack: asyncio.Event = field(default_factory=asyncio.Event) + chunk_task: Optional[asyncio.Task[Any]] = None + resume_key: Optional[_ResumeKey] = None + resume_fingerprint: Any = None + skip_runner: bool = False + done: bool = False + + +class RunnerRuntimeAdapter(RuntimeAdapter): + """把 ``BaseRunner`` 映射为平台六动词的通用 adapter。""" + + def __init__(self, runner: BaseRunner, *, runtime_type: str) -> None: + super().__init__(_RunnerAsBaseRuntime(runner, runtime_type)) + self._runner = runner + self._runtime_type = runtime_type + self._active_runs: dict[str, _ActiveRun] = {} + self._known_runs: set[str] = set() + self._run_sessions: dict[str, str] = {} + self._pending_cancels: set[str] = set() + self._resume_decisions: dict[_ResumeKey, Any] = {} + self._consumed_resumes: set[_ResumeKey] = set() + self._seq = 0 + # 可观测:最近一次 cancel 级联丢弃的 pending 审批集(contract test 断言用)。 + self.last_cancel_dropped_approvals: set[str] = set() + + # ---- 框架钩子(子类按需 override) ---- + + def _checkpoint_capability(self) -> CheckpointCapability: + """诚实暴露 checkpoint 粒度。默认读 runner.describe_checkpoint_capability。""" + raw = {} + describe = getattr(self._runner, "describe_checkpoint_capability", None) + if callable(describe): + try: + raw = dict(describe()) + except Exception: # noqa: BLE001 + raw = {} + supported = bool(raw.get("Supported")) + granularity = _coerce_literal( + raw.get("Granularity"), + ("delta", "snapshot", "none"), + "snapshot" if supported else "none", + ) + rollback_scope = _coerce_literal( + raw.get("RollbackScope"), + ("turn", "invocation", "none"), + "invocation" if supported else "none", + ) + return CheckpointCapability( + supported=supported, + granularity=granularity, + rollback_scope=rollback_scope, + fork_supported=bool(raw.get("ForkSupported", False)), + durable=bool(raw.get("Durable", False)), + shared_across_pods=bool(raw.get("SharedAcrossPods", False)), + reason=str(raw.get("Reason") or ""), + ) + + async def _resume_native( + self, + handle: RunHandle, + target: ResumeTarget, + payload: Optional[ResumePayload], + ) -> Optional[dict]: + """框架原生 resume:返回注入下一次 ``stream()`` 的 runner_input 覆盖。 + + 返回的 dict 会被 :meth:`_build_runner_input` 合并,以框架 runner 真正消费的 + ``checkpoint_resume`` + ``framework_ref`` 形式驱动真实恢复(而非仅写 + ``native_ref`` 摆设)。默认通用形式;ADK/LangGraph 子类覆盖为各自框架结构。 + 返回 ``None`` 表示本框架无可注入的 resume 输入(退化为普通 stream)。 + """ + return { + "checkpoint_resume": True, + "run_id": target.id, + "framework_ref": {self._runtime_type: {f"{target.kind}": target.id}}, + "input": payload.data if payload else None, + } + + # ---- 六动词 ---- + + async def start(self, request: StartRequest) -> RunHandle: + run_id = str(request.metadata.get("invocation_id") or self._next_invocation_id()) + prepared_start = await prepare_runtime_start(request, self._runner) + handle = RunHandle( + run_id=run_id, + session_id=request.session_id, + runtime_type=self._runtime_type, + native_ref={"user_id": request.user_id, "agent_id": request.agent_id}, + ) + self._known_runs.add(run_id) + self._run_sessions[run_id] = request.session_id + self._active_runs[run_id] = _ActiveRun(invocation_id=run_id, session_id=request.session_id) + # 暂存 start 输入,供 stream() 使用。 + self._active_runs[run_id].__dict__["_start_request"] = request + if prepared_start is not None: + self._active_runs[run_id].__dict__["_prepared_start"] = prepared_start + return handle + + def stream(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent]: + return self._stream_events(handle) + + def is_handle_attached(self, handle: RunHandle) -> bool: + return ( + handle.runtime_type == self._runtime_type + and handle.run_id in self._known_runs + and self._run_sessions.get(handle.run_id) == handle.session_id + ) + + async def attach(self, handle: RunHandle) -> RunHandle: + """Restore a persisted handle through an explicit native runner seam. + + Merely repopulating ``_known_runs`` would make validation pass without + restoring framework state. A runner therefore has to expose + ``attach_runtime_handle(handle)`` and prove that its durable backend can + resolve the referenced session/checkpoint. + """ + if self.is_handle_attached(handle): + return handle + if handle.runtime_type != self._runtime_type: + raise ValueError( + f"run handle runtime {handle.runtime_type!r} does not match " + f"adapter {self._runtime_type!r}" + ) + attach = getattr(self._runner, "attach_runtime_handle", None) + if not callable(attach): + raise RuntimeError( + f"runner for {self._runtime_type!r} has no durable " + "attach_runtime_handle capability" + ) + restored = attach(handle) + if inspect.isawaitable(restored): + restored = await restored + if restored is False or restored is None: + raise RuntimeError( + f"runner could not restore persisted run {handle.run_id!r} " + f"for session {handle.session_id!r}" + ) + if isinstance(restored, RunHandle) and restored != handle: + raise ValueError("native runner attached a different run handle") + self._known_runs.add(handle.run_id) + self._run_sessions[handle.run_id] = handle.session_id + return handle + + async def cancel(self, handle: RunHandle) -> CancelResult: + run_id = handle.run_id + run = self._active_runs.get(run_id) + # 活跃 turn = 正在 streaming(run.stream 非 None 且未 done); + # start 过但未 streaming / 已结束 → 无活跃 turn(记 pending 或 not_running)。 + is_active = run is not None and not run.done and run.stream is not None + if not is_active: + if run_id in self._known_runs: + # 无活跃 turn 但 invocation 已知 → 记 pending,下个 turn 消费。 + self._pending_cancels.add(run_id) + return CancelResult.PENDING_CANCEL_RECORDED + return CancelResult.NOT_RUNNING + # 有活跃 turn:interrupt + 级联丢弃 pending 审批。 + assert run is not None # is_active 蕴含 run 非 None + try: + await self._interrupt_active_run(run) + # 级联丢弃该 turn 的 pending 工具审批(先快照供观测)。 + self.last_cancel_dropped_approvals = set(run.pending_approvals) + run.pending_approvals.clear() + run.done = True + self._active_runs.pop(run_id, None) + self._pending_cancels.discard(run_id) + return CancelResult.INTERRUPTED_ACTIVE_TURN + except Exception: # noqa: BLE001 + logger.exception("cancel active run %s 失败", run_id) + return CancelResult.FAILED + + async def resume( + self, + handle: RunHandle, + target: ResumeTarget, + payload: Optional[ResumePayload], + ) -> RunHandle: + if ( + handle.runtime_type != self._runtime_type + or handle.run_id not in self._known_runs + or self._run_sessions.get(handle.run_id) != handle.session_id + ): + raise ValueError(f"unknown run handle for {self._runtime_type}: {handle.run_id!r}") + if handle.run_id in self._pending_cancels: + return handle + self._require_native_checkpoint_capability() + self._known_runs.add(handle.run_id) + # _resume_native 返回的 runner_input 覆盖存到 run 上,下一次 stream() 经 + # _build_runner_input 消费,以框架原生方式真驱动恢复。 + override = await self._resume_native(handle, target, payload) + resume_key = ( + handle.run_id, + handle.session_id, + target.kind, + target.id, + str(payload.call_id or "") if payload is not None else "", + ) + resume_fingerprint = ( + (payload.kind, copy.deepcopy(payload.data)) if payload is not None else None + ) + existing_fingerprint = self._resume_decisions.get(resume_key, _STREAM_STOP) + if existing_fingerprint is not _STREAM_STOP: + if existing_fingerprint != resume_fingerprint: + raise ValueError(f"conflicting resume for checkpoint {target.id!r}") + current = self._active_runs.get(handle.run_id) + if current is not None and not current.done and current.resume_key == resume_key: + return handle + if resume_key in self._consumed_resumes: + self._active_runs[handle.run_id] = _ActiveRun( + invocation_id=handle.run_id, + session_id=handle.session_id, + resume_key=resume_key, + resume_fingerprint=resume_fingerprint, + skip_runner=True, + ) + return handle + + current = self._active_runs.get(handle.run_id) + if current is not None and not current.done and current.resume_key is not None: + if current.resume_key == resume_key: + if current.resume_fingerprint == resume_fingerprint: + return handle + raise ValueError(f"conflicting resume for checkpoint {target.id!r}") + raise ValueError(f"run {handle.run_id!r} already has an active or pending resume") + + run = _ActiveRun( + invocation_id=handle.run_id, + session_id=handle.session_id, + resume_key=resume_key, + resume_fingerprint=resume_fingerprint, + ) + if override: + run.__dict__["_resume_input_override"] = override + self._resume_decisions[resume_key] = resume_fingerprint + self._active_runs[handle.run_id] = run + return handle + + async def checkpoint(self, handle: RunHandle) -> CheckpointDescriptor: + capability = self._require_native_checkpoint_capability() + checkpoint_id = str(handle.native_ref.get("checkpoint_id") or "").strip() + if not checkpoint_id: + raise RuntimeError( + f"{self._runtime_type} runner has no native checkpoint for run " + f"{handle.run_id!r}" + ) + return CheckpointDescriptor( + checkpoint_id=checkpoint_id, + invocation_id=handle.run_id, + capability=capability, + ref=dict(handle.native_ref), + ) + + def _require_native_checkpoint_capability(self) -> CheckpointCapability: + """Fail closed when a runner cannot create or resume a native checkpoint.""" + capability = self._checkpoint_capability() + if capability.supported: + return capability + detail = capability.reason or "runner does not expose framework checkpoints" + raise RuntimeError( + f"{self._runtime_type} native checkpoint capability is unavailable: {detail}" + ) + + async def close(self, handle: RunHandle) -> None: + run = self._active_runs.pop(handle.run_id, None) + if run is not None: + await self._interrupt_active_run(run) + run.done = True + resume_keys = { + key + for key in self._resume_decisions + if key[0] == handle.run_id and key[1] == handle.session_id + } + for key in resume_keys: + self._resume_decisions.pop(key, None) + self._consumed_resumes.difference_update(resume_keys) + self._pending_cancels.discard(handle.run_id) + self._known_runs.discard(handle.run_id) + self._run_sessions.pop(handle.run_id, None) + close = getattr(self._runner, "close", None) + if callable(close): + result = close() + if inspect.isawaitable(result): + await result + + # ---- 内部:cancel 中断 ---- + + async def _interrupt_active_run(self, run: _ActiveRun) -> None: + """中断活跃 turn:置 interrupt 事件,由 stream 消费侧的竞速循环取消在途 chunk task。 + + 机制链(经实证,见 test_adapter_contract.test_cancel_active_turn_returns_interrupted): + ``interrupt_event.set()`` → :meth:`_map_runner_stream` 的 ``asyncio.wait`` 竞速命中 → + ``chunk_task.cancel()`` → ``CancelledError`` 传入 runner 生成器正在 in-flight 的 + ``__anext__`` await(ADK ``run_async`` 的 LLM/tool 调用就在其中),从而真实打断执行, + 而非仅在事件间停止消费。 + + 前提与边界(诚实声明):ADK / LangGraph 均**不暴露原生 cancel 方法**(Runner 只有 + run/run_async/close 等),因此 asyncio 任务取消是**唯一且正确**的中断机制。它要求 + runner 的执行发生在其 stream async generator 的 ``__anext__`` 内(ADK/LG 流式即如此); + 若某 runner 把实际工作放到与 ``__anext__`` 解耦的后台 task,则不在本机制覆盖范围。 + 不在此跨 task 对正被另一 task 迭代的 async generator 直接 aclose(不可靠)。 + """ + run.interrupt_event.set() + if run.stream is None: + run.cancellation_ack.set() + return + if run.chunk_task is not None: + run.chunk_task.cancel() + await asyncio.wait_for(run.cancellation_ack.wait(), timeout=2) + + # ---- 内部:stream → RuntimeEvent ---- + + def _next_invocation_id(self) -> str: + self._seq += 1 + return f"inv_{self._runtime_type}_{self._seq}" + + def _next_seq(self) -> int: + self._seq += 1 + return self._seq + + async def _stream_events(self, handle: RunHandle) -> AsyncIterator[RuntimeEvent]: + run = self._active_runs.get(handle.run_id) + if run is None: + run = _ActiveRun(invocation_id=handle.run_id, session_id=handle.session_id) + self._active_runs[handle.run_id] = run + + # 消费 pending cancel:start 时若已记 pending,立即中断该 turn。 + if handle.run_id in self._pending_cancels: + self._pending_cancels.discard(handle.run_id) + yield self._event( + handle, + EventType.RUN_CANCELED, + { + "status": "cancelled", + "cancel_result": CancelResult.PENDING_CANCEL_RECORDED.value, + }, + ) + return + + if run.skip_runner: + run.done = True + self._active_runs.pop(handle.run_id, None) + yield self._event( + handle, + EventType.RUN_COMPLETED, + {"status": "already_resumed"}, + ) + return + + if run.resume_key is not None: + self._consumed_resumes.add(run.resume_key) + + yield self._event(handle, EventType.RUN_STARTED, {"status": "in_progress"}) + + request = run.__dict__.get("_start_request") + runner_input = self._build_runner_input(handle, request) + gen: Optional[AsyncIterator[RuntimeEvent]] = None + terminal_event_seen = False + approval_interrupted = False + try: + gen = self._map_runner_stream(handle, runner_input) + run.stream = gen + async for event in gen: + yield event + if event.event_type == EventType.APPROVAL_REQUESTED: + approval_interrupted = True + if event.event_type in { + EventType.RUN_COMPLETED, + EventType.RUN_FAILED, + EventType.RUN_CANCELED, + EventType.RUN_INTERRUPTED, + }: + terminal_event_seen = True + if event.event_type in {EventType.RUN_FAILED, EventType.RUN_CANCELED}: + return + + if run.interrupt_event.is_set() and not terminal_event_seen: + yield self._event( + handle, + EventType.RUN_CANCELED, + { + "status": "cancelled", + "cancel_result": CancelResult.INTERRUPTED_ACTIVE_TURN.value, + }, + ) + elif approval_interrupted and not terminal_event_seen: + yield self._event( + handle, + EventType.RUN_INTERRUPTED, + {"status": "input_required"}, + ) + elif not terminal_event_seen: + yield self._event(handle, EventType.RUN_COMPLETED, {"status": "completed"}) + finally: + run.stream = None + run.done = True + self._active_runs.pop(handle.run_id, None) + + def _build_runner_input(self, handle: RunHandle, request: Optional[StartRequest]) -> dict: + # resume 覆盖优先:_resume_native 注入的 checkpoint_resume + framework_ref + # 直接作为 runner 输入,驱动框架原生恢复。 + run = self._active_runs.get(handle.run_id) + override = run.__dict__.get("_resume_input_override") if run is not None else None + if override is not None: + merged = { + "input": override.get("input"), + "session_id": handle.session_id, + "invocation_id": handle.run_id, + "metadata": 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) + + metadata: Dict[str, Any] = {} + request_config: Dict[str, Any] = {} + input_value: Any = "" + if request is not None: + input_value = request.input + metadata = dict(request.metadata or {}) + request_config = dict(request.config or {}) + return { + **request_config, + "input": input_value, + "session_id": handle.session_id, + "invocation_id": handle.run_id, + "metadata": metadata, + } + + async def _map_runner_stream( + self, handle: RunHandle, runner_input: dict + ) -> AsyncIterator[RuntimeEvent]: + run = self._active_runs.get(handle.run_id) + interrupt = run.interrupt_event if run is not None else None + prepared_start = run.__dict__.get("_prepared_start") if run is not None else None + invocation_context = ( + prepared_start.context if isinstance(prepared_start, PreparedRuntimeStart) else None + ) + scope = ( + platform_invocation_scope(invocation_context) + if invocation_context is not None + else nullcontext() + ) + runner_name = _runner_name(self._runner) + accumulated_output: list[str] = [] + usage: dict[str, Any] = {} + runner_gen: Optional[AsyncIterator[Any]] = None + async with _conversation_span_scope(runner_name) as span: + if isinstance(prepared_start, PreparedRuntimeStart): + _set_conversation_span_attributes( + span, + 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, + runner_name=runner_name, + model=prepared_start.context.model, + response_id=prepared_start.response_id, + ) + _set_conversation_input_attributes(span, prepared_start.input_text) + try: + with scope: + stream_result = self._runner.stream(runner_input) + if inspect.iscoroutine(stream_result): + # runner.stream 若声明为 async def -> AsyncIterator(非 async generator), + # 调用返回 coroutine,需 await 得到迭代器。 + stream_result = await stream_result + runner_gen = cast(AsyncIterator[Any], stream_result) + while True: + # 竞速:下一个 runner chunk vs cancel 中断事件。 + chunk_task = asyncio.ensure_future(_anext_or_stop(runner_gen)) + if run is not None: + run.chunk_task = chunk_task + wait_set = {chunk_task} + interrupt_task = ( + asyncio.ensure_future(interrupt.wait()) + if interrupt is not None + else None + ) + if interrupt_task is not None: + wait_set.add(interrupt_task) + done, pending = await asyncio.wait( + wait_set, return_when=asyncio.FIRST_COMPLETED + ) + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + if interrupt_task is not None and interrupt_task in done: + # cancel 中断:安全关闭 runner 流(同一 task)并停止。 + chunk_task.cancel() + await asyncio.gather(chunk_task, return_exceptions=True) + return + try: + chunk = chunk_task.result() + except asyncio.CancelledError: + if interrupt is not None and interrupt.is_set(): + return + raise + finally: + if run is not None: + run.chunk_task = None + if chunk is _STREAM_STOP: + return + if isinstance(chunk, dict): + chunk_type = str(chunk.get("type") or "") + if chunk_type in {"final", "text", "text_delta"}: + text = self._coerce( + chunk.get("delta") or chunk.get("output") or chunk.get("data") + ) + if text: + accumulated_output.append(text) + raw_usage = chunk.get("usage") + if isinstance(raw_usage, dict): + usage.update(raw_usage) + event = self._chunk_to_event(handle, run, chunk) + if event is not None: + yield event + a2ui_surface = _a2ui_surface_event(chunk) + if a2ui_surface is not None: + event_type, payload = a2ui_surface + yield self._event(handle, event_type, payload) + finally: + if accumulated_output: + _set_conversation_output_attributes(span, "".join(accumulated_output)) + _set_conversation_usage_attributes(span, usage) + if runner_gen is not None: + aclose = getattr(runner_gen, "aclose", None) + if callable(aclose): + try: + await aclose() + except Exception: # noqa: BLE001 + pass + if run is not None: + run.cancellation_ack.set() + + def _chunk_to_event( + self, handle: RunHandle, run: Optional[_ActiveRun], chunk: Any + ) -> Optional[RuntimeEvent]: + if not isinstance(chunk, dict): + return self._event( + handle, EventType.TEXT_DELTA, {"text": str(chunk)}, phase="commentary" + ) + chunk_type = chunk.get("type") + if chunk_type in ("reasoning", "reasoning_delta", "thinking"): + text = self._coerce( + chunk.get("delta") + or chunk.get("content") + or chunk.get("output") + or chunk.get("data") + ) + if not text: + return None + event_type = ( + EventType.REASONING_COMPLETED + if chunk.get("status") in ("completed", "done") + else EventType.REASONING_DELTA + ) + return self._event( + handle, + event_type, + {"text": text}, + phase="commentary", + ) + if chunk_type in ("tool_call", "tool_start"): + call_id = str( + chunk.get("tool_call_id") + or chunk.get("call_id") + or chunk.get("run_id") + or chunk.get("id") + or "" + ) + name = str(chunk.get("tool_name") or chunk.get("name") or "tool") + return self._event( + handle, + EventType.TOOL_CALL_BEGIN, + { + "call_id": call_id or name, + "name": name, + "args": chunk.get("tool_args", chunk.get("args")), + }, + ) + if chunk_type in ("tool_result", "tool_end"): + call_id = str( + chunk.get("tool_call_id") + or chunk.get("call_id") + or chunk.get("run_id") + or chunk.get("id") + or "" + ) + name = str(chunk.get("tool_name") or chunk.get("name") or "tool") + return self._event( + handle, + EventType.TOOL_CALL_END, + { + "call_id": call_id or name, + "name": name, + "result": chunk.get("tool_output", chunk.get("output")), + "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 + 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( + handle, + EventType.APPROVAL_REQUESTED, + { + "approval_id": call_id, + "call_id": call_id, + "kind": "tool", + "detail": detail, + }, + ) + 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 {} + runtime_ref = ( + framework_ref.get(self._runtime_type) if isinstance(framework_ref, dict) else {} + ) or {} + checkpoint_id = str( + runtime_ref.get("checkpoint_id") if isinstance(runtime_ref, dict) else "" + ) + if not checkpoint_id: + return None + handle.native_ref["checkpoint_id"] = checkpoint_id + known_checkpoint_ids = handle.native_ref.setdefault("known_checkpoint_ids", []) + if checkpoint_id not in known_checkpoint_ids: + known_checkpoint_ids.append(checkpoint_id) + handle.native_ref["framework_ref"] = framework_ref + if isinstance(runtime_ref, dict): + handle.native_ref.update(runtime_ref) + return self._event( + handle, + EventType.CHECKPOINT_CREATED, + { + "checkpoint_id": checkpoint_id, + "granularity": self._checkpoint_capability().granularity, + "resume_target": framework_ref, + }, + ) + if chunk_type == "graph_update": + return self._event( + handle, + EventType.RUN_PROGRESS, + { + "status": "in_progress", + "node": str(chunk.get("node") or ""), + "state_update": self._coerce(chunk.get("output")), + }, + ) + if chunk_type == "error": + error = self._coerce(chunk.get("message") or chunk.get("error")) + return self._event( + handle, + EventType.RUN_FAILED, + { + "status": "failed", + "error": error or "runner failed", + }, + ) + if chunk_type == "final": + return self._event( + handle, + EventType.TEXT_COMPLETED, + {"text": self._coerce(chunk.get("output"))}, + phase="final_answer", + ) + text = self._coerce(chunk.get("delta") or chunk.get("output") or chunk.get("data")) + if not text: + return None + return self._event(handle, EventType.TEXT_DELTA, {"text": text}, phase="commentary") + + def _event( + self, + handle: RunHandle, + event_type: str, + payload: dict, + *, + phase: Optional[str] = None, + ) -> RuntimeEvent: + return RuntimeEvent.create( + 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=phase, + payload=payload, + ) + + @staticmethod + def _coerce(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + return str(value) + + +__all__ = ["RunnerRuntimeAdapter", "_RunnerAsBaseRuntime"] diff --git a/ksadk/runtime_context.py b/ksadk/runtime_context.py index 46a654f9..8434b76c 100644 --- a/ksadk/runtime_context.py +++ b/ksadk/runtime_context.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from contextvars import ContextVar, Token -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Iterator @@ -22,10 +22,14 @@ class PlatformInvocationContext: has_current_files: bool runner_type: str account_id: str = "" + metadata: dict[str, Any] = field(default_factory=dict) model: str | None = None model_options: dict[str, Any] | None = None kb_context: dict[str, Any] | None = None memory_context: dict[str, Any] | None = None + # Request-scoped runtime control. It is intentionally separate from + # public caller metadata so built-in tools can enforce it consistently. + tool_approval_mode: str = "" def to_payload(self) -> dict[str, Any]: return { @@ -43,6 +47,7 @@ def to_payload(self) -> dict[str, Any]: "current_attachment_results": list(self.current_attachment_results or []), "has_current_files": self.has_current_files, "runner_type": self.runner_type, + "metadata": dict(self.metadata or {}), "model": self.model, "model_options": dict(self.model_options or {}), } @@ -122,7 +127,14 @@ def set_current_invocation_context( def reset_current_invocation_context( token: Token[PlatformInvocationContext | None], ) -> None: - _CURRENT_PLATFORM_INVOCATION_CONTEXT.reset(token) + try: + _CURRENT_PLATFORM_INVOCATION_CONTEXT.reset(token) + except ValueError: + # ASGI streaming may resume an async generator in a descendant + # Context after it yielded an interrupt. Tokens cannot be reset from + # that different Context; clearing the active descendant avoids + # converting a successfully persisted approval pause into a failed run. + _CURRENT_PLATFORM_INVOCATION_CONTEXT.set(None) def set_current_tool_execution_context( @@ -134,7 +146,10 @@ def set_current_tool_execution_context( def reset_current_tool_execution_context( token: Token[ToolExecutionContext | None], ) -> None: - _CURRENT_TOOL_EXECUTION_CONTEXT.reset(token) + try: + _CURRENT_TOOL_EXECUTION_CONTEXT.reset(token) + except ValueError: + _CURRENT_TOOL_EXECUTION_CONTEXT.set(None) @contextmanager diff --git a/ksadk/runtime_state.py b/ksadk/runtime_state.py index fed63d62..714ede58 100644 --- a/ksadk/runtime_state.py +++ b/ksadk/runtime_state.py @@ -7,7 +7,6 @@ import yaml - STATE_FILE_NAME = ".agentengine.state" diff --git a/ksadk/sandbox/__init__.py b/ksadk/sandbox/__init__.py index 13150027..1bf49685 100644 --- a/ksadk/sandbox/__init__.py +++ b/ksadk/sandbox/__init__.py @@ -1,5 +1,8 @@ from ksadk.sandbox.backends.e2b import E2BSandboxBackend, E2BSandboxSession -from ksadk.sandbox.backends.local_process import LocalProcessSandboxBackend, LocalProcessSandboxSession +from ksadk.sandbox.backends.local_process import ( + LocalProcessSandboxBackend, + LocalProcessSandboxSession, +) from ksadk.sandbox.base import ( SandboxBackend, SandboxCommandResult, diff --git a/ksadk/sandbox/backends/__init__.py b/ksadk/sandbox/backends/__init__.py index 116cb89d..72634231 100644 --- a/ksadk/sandbox/backends/__init__.py +++ b/ksadk/sandbox/backends/__init__.py @@ -1,5 +1,8 @@ from ksadk.sandbox.backends.e2b import E2BSandboxBackend, E2BSandboxSession -from ksadk.sandbox.backends.local_process import LocalProcessSandboxBackend, LocalProcessSandboxSession +from ksadk.sandbox.backends.local_process import ( + LocalProcessSandboxBackend, + LocalProcessSandboxSession, +) __all__ = [ "E2BSandboxBackend", diff --git a/ksadk/sandbox/backends/e2b.py b/ksadk/sandbox/backends/e2b.py index 114d209e..965e1a4d 100644 --- a/ksadk/sandbox/backends/e2b.py +++ b/ksadk/sandbox/backends/e2b.py @@ -67,6 +67,11 @@ def sandbox_id(self) -> str: def write_file(self, path: str, data: str | bytes) -> None: self._sandbox.files.write(path, data) + def write_files(self, files: list[tuple[str, str | bytes]]) -> None: + if not files: + return + self._sandbox.files.write_files([{"path": path, "data": data} for path, data in files]) + def read_file(self, path: str) -> str: return str(self._sandbox.files.read(path)) @@ -78,7 +83,7 @@ def run_command( env: dict[str, str] | None = None, cwd: str | None = None, ) -> SandboxCommandResult: - kwargs = {} + kwargs: dict[str, Any] = {} if timeout is not None: kwargs["timeout"] = timeout if env is not None: @@ -121,9 +126,11 @@ def create_session( sandbox_cls = self.sandbox_cls if sandbox_cls is None: try: - from e2b import Sandbox + from e2b import Sandbox # type: ignore[import-untyped] except ImportError as exc: - raise SandboxError("e2b>=2.0.0 is required for KSADK_SANDBOX_BACKEND=e2b") from exc + raise SandboxError( + "e2b>=2.15.3,<2.25.0 is required for KSADK_SANDBOX_BACKEND=e2b" + ) from exc sandbox_cls = Sandbox metadata = { @@ -142,8 +149,9 @@ def create_session( ) session = self._wrap_sandbox(sandbox) self._wait_until_ready(session) - for item in input_files or []: - session.write_file(item.target_path, item.source.read_bytes()) + session.write_files( + [(item.target_path, item.source.read_bytes()) for item in input_files or []] + ) return session def _wrap_sandbox(self, sandbox: Any) -> E2BSandboxSession: diff --git a/ksadk/sandbox/backends/local_process.py b/ksadk/sandbox/backends/local_process.py index e986606f..fa9f81a0 100644 --- a/ksadk/sandbox/backends/local_process.py +++ b/ksadk/sandbox/backends/local_process.py @@ -8,8 +8,16 @@ from ksadk.sandbox.base import SandboxCommandResult, SandboxInputFile, SandboxSession +def _coerce_output(value: str | bytes | None) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value or "" + + class LocalProcessSandboxSession: - def __init__(self, *, session_id: str, workspace_root: Path, backend_name: str = "local_process"): + def __init__( + self, *, session_id: str, workspace_root: Path, backend_name: str = "local_process" + ): self._session_id = session_id self._workspace_root = workspace_root.expanduser().resolve() self._workspace_root.mkdir(parents=True, exist_ok=True) @@ -67,8 +75,8 @@ def run_command( pass stdout, stderr = process.communicate() return SandboxCommandResult( - stdout=stdout or exc.stdout or "", - stderr=(stderr or exc.stderr or "") + "\ncommand timed out", + stdout=_coerce_output(stdout or exc.stdout), + stderr=_coerce_output(stderr or exc.stderr) + "\ncommand timed out", exit_code=124, ) diff --git a/ksadk/sandbox/base.py b/ksadk/sandbox/base.py index a106dc25..b5aafe57 100644 --- a/ksadk/sandbox/base.py +++ b/ksadk/sandbox/base.py @@ -20,7 +20,9 @@ class SandboxType(str, Enum): def from_value(cls, value: str | "SandboxType" | None) -> "SandboxType": if isinstance(value, SandboxType): return value - normalized = (value or "").strip().lower().replace("_", "").replace("-", "").replace(" ", "") + normalized = ( + (value or "").strip().lower().replace("_", "").replace("-", "").replace(" ", "") + ) aliases = { "": cls.AIO, "aio": cls.AIO, @@ -64,14 +66,11 @@ class SandboxSpec: class SandboxSession(Protocol): @property - def sandbox_id(self) -> str: - ... + def sandbox_id(self) -> str: ... - def write_file(self, path: str, data: str | bytes) -> None: - ... + def write_file(self, path: str, data: str | bytes) -> None: ... - def read_file(self, path: str) -> str: - ... + def read_file(self, path: str) -> str: ... def run_command( self, @@ -80,14 +79,11 @@ def run_command( timeout: int | None = None, env: dict[str, str] | None = None, cwd: str | None = None, - ) -> SandboxCommandResult: - ... + ) -> SandboxCommandResult: ... - def get_host(self, port: int) -> str: - ... + def get_host(self, port: int) -> str: ... - def kill(self) -> None: - ... + def kill(self) -> None: ... class SandboxBackend(Protocol): @@ -97,5 +93,4 @@ def create_session( session_id: str, env: dict[str, str] | None = None, input_files: list[SandboxInputFile] | None = None, - ) -> SandboxSession: - ... + ) -> SandboxSession: ... diff --git a/ksadk/sandbox/factory.py b/ksadk/sandbox/factory.py index 2f569673..2efdb734 100644 --- a/ksadk/sandbox/factory.py +++ b/ksadk/sandbox/factory.py @@ -23,11 +23,17 @@ def create_sandbox_backend( ) -> SandboxBackend: resolved = (backend or os.environ.get("KSADK_SANDBOX_BACKEND") or "e2b").strip().lower() if resolved in {"local", "local_process"}: - return LocalProcessSandboxBackend(workspace_root=resolve_local_session_dir() / "workspace", backend_name="local_process") + return LocalProcessSandboxBackend( + workspace_root=resolve_local_session_dir() / "workspace", backend_name="local_process" + ) if resolved in {"pod", "pod_process"}: if not bool_env("KSADK_ALLOW_POD_PROCESS_TOOLS", False): - raise SandboxError("KSADK_ALLOW_POD_PROCESS_TOOLS=true is required for pod_process backend") - return LocalProcessSandboxBackend(workspace_root=resolve_local_session_dir() / "workspace", backend_name="pod_process") + raise SandboxError( + "KSADK_ALLOW_POD_PROCESS_TOOLS=true is required for pod_process backend" + ) + return LocalProcessSandboxBackend( + workspace_root=resolve_local_session_dir() / "workspace", backend_name="pod_process" + ) if resolved != "e2b": raise SandboxError(f"Unsupported sandbox backend: {resolved}") return E2BSandboxBackend(spec=sandbox_spec_from_env(), sandbox_cls=sandbox_cls) @@ -39,7 +45,11 @@ def sandbox_spec_from_env() -> SandboxSpec: or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID") or "" ) - timeout = int(os.environ.get("KSADK_SANDBOX_TIMEOUT") or os.environ.get("KSADK_SKILL_RUNTIME_TIMEOUT") or "900") + timeout = int( + os.environ.get("KSADK_SANDBOX_TIMEOUT") + or os.environ.get("KSADK_SKILL_RUNTIME_TIMEOUT") + or "900" + ) allow_internet_access = bool_env( "KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", bool_env("KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS", True), diff --git a/ksadk/sandbox/registry.py b/ksadk/sandbox/registry.py index 28a632ff..6dc08aa4 100644 --- a/ksadk/sandbox/registry.py +++ b/ksadk/sandbox/registry.py @@ -1,11 +1,14 @@ from __future__ import annotations import atexit +import contextvars import logging import os import threading import time +from contextlib import contextmanager from dataclasses import dataclass +from typing import Any, Iterator from ksadk.sandbox.base import SandboxBackend, SandboxSession @@ -43,7 +46,9 @@ def __init__(self): @staticmethod def _resolve_sweep_interval() -> int: - raw = os.environ.get("KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", str(_DEFAULT_SWEEP_INTERVAL_SECONDS)) + raw = os.environ.get( + "KSADK_SANDBOX_SWEEP_INTERVAL_SECONDS", str(_DEFAULT_SWEEP_INTERVAL_SECONDS) + ) try: value = int(raw) except (TypeError, ValueError): @@ -103,7 +108,8 @@ def sweep(self, *, now: float | None = None, idle_ttl_seconds: int | None = None expired = [ key for key, entry in self._entries.items() - if entry.expires_at <= current or (idle_ttl > 0 and current - entry.last_used_at > idle_ttl) + if entry.expires_at <= current + or (idle_ttl > 0 and current - entry.last_used_at > idle_ttl) ] for key in expired: self.kill(key) @@ -138,7 +144,8 @@ def _enforce_quota(self, *, max_sessions: int | None, keep_key: str) -> None: with self._lock: while len(self._entries) - len(to_kill) >= max_sessions: candidates = [ - entry for entry in self._entries.values() + entry + for entry in self._entries.values() if entry.key != keep_key and entry.key not in to_kill ] if not candidates: @@ -171,17 +178,61 @@ def _sweep_loop(self) -> None: def reset_for_tests(self) -> None: """停后台 sweep 线程并清空所有 entry,用于测试隔离。""" + self.close() + self._idle_ttl_seconds = 0 + self._sweep_stop.clear() + + def close(self) -> None: + """Stop the owned sweep thread and kill every sandbox session.""" self._sweep_stop.set() thread = self._sweep_thread if thread is not None and thread.is_alive(): thread.join(timeout=2.0) self._sweep_thread = None self.clear() - self._idle_ttl_seconds = 0 - self._sweep_stop.clear() -GLOBAL_SANDBOX_REGISTRY = SandboxRegistry() +_current_sandbox_registry: contextvars.ContextVar[SandboxRegistry | None] = contextvars.ContextVar( + "ksadk_sandbox_registry", default=None +) +_fallback_sandbox_registry = SandboxRegistry() + + +def set_fallback_sandbox_registry(registry: SandboxRegistry) -> None: + """Set the registry used by legacy calls outside a bound app context.""" + global _fallback_sandbox_registry + _fallback_sandbox_registry = registry + + +@contextmanager +def bind_sandbox_registry(registry: SandboxRegistry) -> Iterator[None]: + token = _current_sandbox_registry.set(registry) + try: + yield + finally: + _current_sandbox_registry.reset(token) + + +def get_sandbox_registry() -> SandboxRegistry: + return _current_sandbox_registry.get() or _fallback_sandbox_registry + + +class _ContextualSandboxRegistry: + """Compatibility proxy resolving the registry owned by the current app.""" + + def __getattr__(self, name: str) -> Any: + return getattr(get_sandbox_registry(), name) + + def clear(self) -> None: + # Keep atexit dynamic: resolve the default app registry when invoked, + # not when the callback is registered. + get_sandbox_registry().clear() + + def close(self) -> None: + get_sandbox_registry().close() + + +GLOBAL_SANDBOX_REGISTRY = _ContextualSandboxRegistry() # 进程退出时清理 sandbox,避免 E2B sandbox 活到服务端 timeout 才销毁(按秒计费)。 # clear() 幂等,与 server lifespan shutdown 重复调用安全。kill -9 不触发 atexit,只能靠 E2B 兜底。 atexit.register(GLOBAL_SANDBOX_REGISTRY.clear) diff --git a/ksadk/server/api_models.py b/ksadk/server/api_models.py index 7935bb84..2563c90a 100644 --- a/ksadk/server/api_models.py +++ b/ksadk/server/api_models.py @@ -1,13 +1,16 @@ -from typing import List, Optional, Dict, Any, Union -from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel # --- Consolidating Part Types --- + class FunctionCall(BaseModel): id: Optional[str] = None name: str args: Dict[str, Any] + class FunctionResponse(BaseModel): id: Optional[str] = None name: str @@ -32,19 +35,22 @@ class Part(BaseModel): functionResponse: Optional[FunctionResponse] = None inlineData: Optional[InlineData] = None fileData: Optional[FileData] = None - - # Simple alias for JSON field mapping if needed, + + # Simple alias for JSON field mapping if needed, # but Pydantic usually handles this with field aliases if inputs differ. # For now we stick to the TS interface names. + class GenAiContent(BaseModel): role: str = "user" parts: List[Part] + class NewMessage(BaseModel): parts: List[Part] role: str = "user" + class AgentRunRequest(BaseModel): appName: str userId: str @@ -56,18 +62,22 @@ class AgentRunRequest(BaseModel): functionCallEventId: Optional[str] = None model: Optional[str] = None + # --- Response Types --- + class LlmResponse(BaseModel): content: Optional[GenAiContent] = None error: Optional[str] = None + class Session(BaseModel): id: str userId: str appName: str createdTime: Optional[float] = None updatedTime: Optional[float] = None - + + class AppListResponse(BaseModel): apps: List[str] diff --git a/ksadk/server/app.py b/ksadk/server/app.py index 904fd8dd..09f453d4 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -1,241 +1,442 @@ # ksadk/server/app.py -""" -FastAPI 应用 - 提供 HTTP API 接口 (ADK Web 兼容) -""" +"""Default runtime app facade and backwards-compatible server exports.""" + +from __future__ import annotations -import asyncio -import base64 -import io -import json -import logging import os -import time -import uuid -import zipfile -from contextlib import asynccontextmanager -from datetime import datetime, timezone -from pathlib import Path, PurePosixPath -from typing import Any, AsyncIterator, Dict, List, Mapping, Optional -from urllib.parse import quote +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any -import httpx -from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, Response, StreamingResponse -from pydantic import BaseModel, Field +from fastapi import FastAPI +from fastapi.responses import StreamingResponse import ksadk.conversations as conversation -from ksadk.conversations.attachment_storage import AttachmentStorageService -from ksadk.conversations.attachments import compact_attachment_result_for_session -from ksadk.conversations.model_context import normalize_model_metadata -from ksadk.conversations.run_kinds import ( - RUN_MODE_BACKGROUND, - RUN_MODE_FOREGROUND, - RUN_MODE_UNKNOWN, - RUN_TRIGGER_CHECKPOINT_RESUME, - RUN_TRIGGER_NEW_RUN, - RUN_TRIGGER_UNKNOWN, - trigger_from_resume_input, +from ksadk.server.composition import configure_runtime_app +from ksadk.server.factory import ( + RuntimeAppConfig, + RuntimeAppState, + create_runtime_app, + get_state, + set_fallback_state, + shutdown_runtime_resources, ) -from ksadk.conversations.run_status import RUN_STATUS_ACTIVE, RUN_STATUS_TERMINAL -from ksadk.conversations.session_title import ( - HEURISTIC_SESSION_TITLE_SOURCE, - build_fallback_title, - build_heuristic_title, +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.runners.base_runner import BaseRunner -from ksadk.runtime_state import load_state as load_runtime_state -from ksadk.server.api_models import AgentRunRequest -from ksadk.server.terminal_sessions import ( - TerminalSessionManager, - native_terminal_supported, - register_terminal_routes, +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.sessions import ( - ConversationSessionCore, - Session, - SessionEvent, - describe_session_backend, - resolve_session_service, +from ksadk.server.routes.control import ( + get_checkpoint_resume_preview_action, + resume_run_action, + subscribe_run_events_action, ) -from ksadk.sessions.errors import SessionBackendUnavailable -from ksadk.sessions.local_service import resolve_local_session_dir -from ksadk.toolsets import describe_agentengine_tools -from ksadk.tracing import get_memory_exporter -from ksadk.ui_config import UI_PROFILE_CUSTOM, resolve_ui_config -from ksadk_runtime_common.workspace_files import ( - build_workspace_files_bootstrap, - create_workspace_files_router, - workspace_files_enabled, +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_runtime_common.workspace_files.preview import ( - build_workspace_file_base_href, - build_workspace_preview_csp, - inject_workspace_html_preview, +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 -logger = logging.getLogger(__name__) - - -# Global Runner instance -runner: BaseRunner = None -_runner_loaded = False -_DETACHED_STREAMS: set[asyncio.Task[Any]] = set() -_DETACHED_STREAMS_BY_INVOCATION: dict[str, "_DetachedSSEStream"] = {} -_DETACHED_RESUME_KEYS_BY_INVOCATION: dict[str, tuple[str, str]] = {} -_ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY: dict[tuple[str, str], str] = {} -# run_status 事件状态集合:canonical 定义在 ksadk.conversations.run_status, -# 这里保留旧名做兼容别名(RUN_STATUS_TERMINAL 已含 resume_failed)。 -_RUN_TERMINAL_STATUSES = RUN_STATUS_TERMINAL -_RUN_ACTIVE_STATUSES = RUN_STATUS_ACTIVE - - -def _parse_iso_datetime(value: Any) -> datetime | None: - raw = str(value or "").strip() - if not raw: - return None - if raw.endswith("Z"): - raw = f"{raw[:-1]}+00:00" - try: - parsed = datetime.fromisoformat(raw) - except ValueError: - return None - if parsed.tzinfo is None: - return parsed.replace(tzinfo=timezone.utc) - return parsed.astimezone(timezone.utc) - - -_RESERVED_UI_PATHS = {"/", "/chat", "/build", "/deploy"} -_CUSTOM_API_PROXY_ENV_KEYS = ("KSADK_USER_BACKEND_URL", "LUOLUO_USER_BACKEND_URL") -_HOP_BY_HOP_HEADERS = { - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "host", - "content-length", -} - - -class _DetachedSSEStream: - _MAX_BACKLOG_CHUNKS = 256 - - def __init__( - self, - source: AsyncIterator[str], - *, - invocation_id: str | None = None, - session_id: str | None = None, - run_mode: str = "unknown", - run_trigger: str = "unknown", - ): - self._source = source - self.invocation_id = invocation_id - self.session_id = session_id - self._run_mode = run_mode - self._run_trigger = run_trigger - self._subscribers: set[asyncio.Queue[str | None]] = set() - self._backlog: list[str] = [] - self._done = False - self._task = asyncio.create_task(self._consume()) - _DETACHED_STREAMS.add(self._task) - self._task.add_done_callback(_DETACHED_STREAMS.discard) - if self.invocation_id: - _DETACHED_STREAMS_BY_INVOCATION[self.invocation_id] = self - self._task.add_done_callback( - lambda _task: _DETACHED_STREAMS_BY_INVOCATION.pop(self.invocation_id or "", None) - ) - - async def _has_terminal_run_status(self) -> bool: - if not self.session_id or not self.invocation_id: - return False - service = resolve_session_service() - for event in reversed(await service.get_events(self.session_id)): - if event.invocation_id != self.invocation_id or event.event_type != "run_status": - continue - status = str((event.content or {}).get("status") or "").strip().lower() - return status in _RUN_TERMINAL_STATUSES - return False - - async def _consume(self) -> None: - terminal_fallback_status: str | None = None - try: - async for chunk in self._source: - self._backlog.append(chunk) - if len(self._backlog) > self._MAX_BACKLOG_CHUNKS: - self._backlog = self._backlog[-self._MAX_BACKLOG_CHUNKS :] - subscribers = list(self._subscribers) - if not subscribers: - continue - await asyncio.gather( - *(subscriber.put(chunk) for subscriber in subscribers), - return_exceptions=True, - ) - except asyncio.CancelledError: - terminal_fallback_status = "cancelled" - raise - except Exception: - terminal_fallback_status = "failed" - logger.exception("Detached SSE stream failed") - raise - finally: - self._done = True - subscribers = list(self._subscribers) - if subscribers: - await asyncio.gather( - *(subscriber.put(None) for subscriber in subscribers), - return_exceptions=True, - ) - if terminal_fallback_status and self.session_id: - try: - if not await self._has_terminal_run_status(): - await conversation.append_run_status_event( - session_id=self.session_id, - author="system", - status=terminal_fallback_status, - invocation_id=self.invocation_id or "", - detail=( - f"background_{terminal_fallback_status}:{self.invocation_id or ''}" - ), - session_service_provider=resolve_session_service, - run_mode=self._run_mode, - run_trigger=self._run_trigger, - ) - except Exception: - logger.exception("failed to write background terminal status fallback") - - def subscribe(self) -> asyncio.Queue[str | None]: - queue: asyncio.Queue[str | None] = asyncio.Queue() - for chunk in self._backlog: - queue.put_nowait(chunk) - if self._done: - queue.put_nowait(None) - else: - self._subscribers.add(queue) - return queue +# 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, +) - def unsubscribe(self, queue: asyncio.Queue[str | None]) -> None: - self._subscribers.discard(queue) - def cancel(self) -> bool: - if self._task.done(): - return False - return self._task.cancel() +SSE_HEARTBEAT_INTERVAL_SECONDS = max( + 1.0, + float(os.getenv("AGENTENGINE_SSE_HEARTBEAT_SECONDS", "15")), +) - async def iter_for_client(self) -> AsyncIterator[str]: - queue = self.subscribe() - try: - while True: - chunk = await queue.get() - if chunk is None: - break - yield chunk - finally: - self.unsubscribe(queue) +_GROUP_ROUTERS = GROUP_ROUTERS +_configure_runtime_app = configure_runtime_app +_shutdown_runner_resources = shutdown_runtime_resources def _detached_streaming_response( @@ -247,6 +448,7 @@ def _detached_streaming_response( 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, @@ -254,3678 +456,46 @@ def _detached_streaming_response( run_mode=run_mode, run_trigger=run_trigger, ) + registry = detached._registry if invocation_id and resume_key: - _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key - _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY[resume_key] = invocation_id + 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(invocation_id, resume_key) + lambda _task: _clear_detached_resume_key(registry, invocation_id, resume_key) ) return StreamingResponse(detached.iter_for_client(), media_type="text/event-stream") -async def _cancel_detached_streams_for_session(session_id: str) -> None: - target_session_id = str(session_id or "").strip() - if not target_session_id: - return - detached_streams = [ - detached - for detached in list(_DETACHED_STREAMS_BY_INVOCATION.values()) - if detached.session_id == target_session_id - ] - for detached in detached_streams: - detached.cancel() - if detached_streams: - await asyncio.gather( - *(detached._task for detached in detached_streams), - return_exceptions=True, - ) - - -def _clear_detached_resume_key(invocation_id: str, resume_key: tuple[str, str]) -> None: - _DETACHED_RESUME_KEYS_BY_INVOCATION.pop(invocation_id, None) - if _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.get(resume_key) == invocation_id: - _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.pop(resume_key, None) - - -def _detached_resume_key_from_input( - session_id: str | None, - resume_input: Mapping[str, Any] | None, -) -> tuple[str, str] | None: - if not isinstance(resume_input, Mapping): - return None - if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint": - return None - normalized_session_id = str(session_id or "").strip() - run_id = str(resume_input.get("run_id") or "").strip() - if not normalized_session_id or not run_id: - return None - return normalized_session_id, run_id - - -def _reject_if_detached_resume_active(resume_key: tuple[str, str] | None) -> None: - if resume_key is None: - return - active_resume_invocation_id = _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.get(resume_key) - if not active_resume_invocation_id: - return - raise HTTPException( - status_code=409, - detail={ - "code": "resume_already_running", - "message": "A checkpoint resume is already running for this session and run.", - "invocation_id": active_resume_invocation_id, - "session_id": resume_key[0], - "run_id": resume_key[1], - }, - ) - - -async def _shutdown_runner_resources(): - terminal_manager.reset_for_tests() - pending_streams = list(_DETACHED_STREAMS) - for task in pending_streams: - task.cancel() - if pending_streams: - await asyncio.gather(*pending_streams, return_exceptions=True) - _DETACHED_STREAMS.clear() - _DETACHED_STREAMS_BY_INVOCATION.clear() - _DETACHED_RESUME_KEYS_BY_INVOCATION.clear() - _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.clear() - - active_runner = runner - if active_runner is None: - return - close = getattr(active_runner, "close", None) - if callable(close): - await close() - - # 释放 sandbox registry 占用的 E2B sandbox,避免进程退出后仍按秒计费到 E2B 服务端 timeout。 - # clear() 幂等,与模块级 atexit 重复调用安全。 - try: - from ksadk.sandbox.registry import GLOBAL_SANDBOX_REGISTRY - - GLOBAL_SANDBOX_REGISTRY.clear() - except Exception: - logger.exception("failed to clear sandbox registry on shutdown") - - -@asynccontextmanager -async def _lifespan(_app: FastAPI) -> AsyncIterator[None]: - try: - yield - finally: - await _shutdown_runner_resources() - - -# Create and configure the FastAPI application -app = FastAPI( - title="ADK Core API", - description="Agent Development Kit HTTP API", - version="1.0.0", - lifespan=_lifespan, -) - - -# Middleware for disabling cache on frontend entry points -@app.middleware("http") -async def no_cache_frontend(request: Request, call_next): - 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" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" - return response - - -# Configure CORS (permissive by default for ADK tools) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -_TEXT_MIME_PREFIXES = ("text/",) -_TEXT_MIME_TYPES = { - "application/json", - "application/pdf", - "application/xml", - "application/yaml", - "application/x-yaml", - "application/x-ndjson", -} -_TEXT_FILE_EXTENSIONS = { - ".txt", - ".md", - ".markdown", - ".json", - ".yaml", - ".yml", - ".csv", - ".tsv", - ".log", - ".py", - ".js", - ".ts", - ".jsx", - ".tsx", - ".html", - ".css", - ".sql", - ".xml", - ".sh", -} -_MAX_INLINE_BASE64_CHARS = 4_000_000 -_MAX_INLINE_TEXT_CHARS = 20_000 -_MAX_REFERENCE_TEXT_BYTES = 3_000_000 -_UPLOAD_URI_SCHEME = "ksadk-upload://" - - -def _workspace_root_dir() -> Path: - return resolve_local_session_dir() / "workspace" - - -_NATIVE_TUI_FRAMEWORKS = {"hermes", "openclaw"} - - -def _current_framework() -> str: - 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() - - -def _build_native_terminal_capability(framework: str) -> dict[str, Any]: - enabled = ( - native_terminal_supported() - and str(framework or "").strip().lower() in _NATIVE_TUI_FRAMEWORKS - ) - return { - "Enabled": enabled, - "Mode": "tui" if enabled else None, - "Protocol": "ks-terminal.v1", - "Path": "/_ksadk/terminal/ws" if enabled else None, - } - - -terminal_manager = TerminalSessionManager( - workspace_root_getter=_workspace_root_dir, - framework_getter=_current_framework, -) - -app.include_router( - create_workspace_files_router( - root_getter=_workspace_root_dir, - enabled_getter=lambda: workspace_files_enabled(default=True), - ) -) -register_terminal_routes(app, terminal_manager) - - -@app.exception_handler(SessionBackendUnavailable) -async def session_backend_unavailable_handler( - _request: Request, - exc: SessionBackendUnavailable, -): - return Response( - content=json.dumps( - { - "detail": { - "code": "session_backend_unavailable", - "message": str(exc), - } - }, - ensure_ascii=False, +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 ), - status_code=503, - media_type="application/json", + detached_stream_class=lambda: _DetachedSSEStream, + heartbeat_interval=lambda: SSE_HEARTBEAT_INTERVAL_SECONDS, + runtime_app=lambda: app, ) - - -def set_runner(r: BaseRunner): - global runner, _runner_loaded - runner = r - _runner_loaded = False - - -def _ensure_runner_loaded() -> BaseRunner: - global _runner_loaded - if not runner: - raise HTTPException(status_code=500, detail="Runner 未初始化") - if _runner_loaded: - return runner - - runner.load_agent() - _runner_loaded = True - return runner - - -def _resolve_active_runner() -> BaseRunner: - try: - return _ensure_runner_loaded() - except HTTPException: - raise - except Exception as exc: - logger.warning("Runner 加载失败: %s", exc) - raise HTTPException(status_code=500, detail=str(exc) or "Runner 加载失败") from exc - - -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")), - ("MODEL_NAME", os.getenv("MODEL_NAME")), - ("COZE_MODEL_NAME", os.getenv("COZE_MODEL_NAME")), - ) - for source, value in candidates: - model = str(value or "").strip() - if model: - return model, source - return None, None - - -def _build_bootstrap_model_payload() -> Optional[dict[str, Any]]: - current_model, source = _resolve_current_model() - if not current_model: - return None - - payload = normalize_model_metadata({"id": current_model}) - payload["source"] = source - return payload - - -def _runner_project_dir() -> Path: - if runner and getattr(runner, "project_dir", None): - try: - return Path(str(runner.project_dir)).resolve() - except Exception: - pass - return Path(".").resolve() - - -def _default_custom_ui_bundle_dir(project_dir: Path) -> Path: - return project_dir / "research-ui" / "dist" - - -def _ui_state_with_env_fallback(state: dict[str, Any]) -> dict[str, Any]: - merged = dict(state or {}) - env_fallbacks = { - "ui_profile": os.environ.get("KSADK_UI_PROFILE"), - "ui_path": os.environ.get("KSADK_UI_PATH"), - "ui_url": os.environ.get("KSADK_UI_URL"), - "ui_bundle_path": os.environ.get("KSADK_UI_BUNDLE_PATH"), - } - for key, value in env_fallbacks.items(): - if value and not merged.get(key): - merged[key] = value - return merged - - -def _resolve_agent_ui_spec() -> dict[str, Any]: - project_dir = _runner_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) - if ( - not state.get("ui_profile") - and not state.get("ui_path") - and not state.get("ui_url") - and (auto_custom_bundle_dir / "index.html").exists() - ): - state["ui_profile"] = UI_PROFILE_CUSTOM - state["ui_path"] = "/" - state["ui_bundle_path"] = str(auto_custom_bundle_dir) - config = resolve_ui_config( - framework=framework, - state=state, - cli_profile=None, - cli_path=None, - cli_url=None, - ) - - if config.profile == UI_PROFILE_CUSTOM: - bundle_dir_value = state.get("ui_bundle_path") or state.get("ui_bundle_dir") - bundle_dir = None - if bundle_dir_value: - candidate = Path(str(bundle_dir_value)) - if not candidate.is_absolute(): - candidate = project_dir / candidate - if candidate.exists(): - bundle_dir = candidate.resolve() - if bundle_dir is None: - bundle_dir = _default_custom_ui_bundle_dir(project_dir) - index_file = bundle_dir / "index.html" - enabled = bundle_dir.exists() and index_file.exists() - return { - "enabled": enabled, - "profile": config.profile, - "ui_profile": config.profile, - "path": config.path, - "ui_path": config.path, - "url": config.url, - "ui_url": config.url, - "bundle_path": str(bundle_dir), - "ui_bundle_path": str(bundle_dir), - "index_path": str(index_file), - "source": "custom", - } - - bundle_dir = STATIC_DIR - index_file = bundle_dir / "index.html" - enabled = bundle_dir.exists() and index_file.exists() - return { - "enabled": enabled, - "profile": config.profile, - "ui_profile": config.profile, - "path": config.path, - "ui_path": config.path, - "url": config.url, - "ui_url": config.url, - "bundle_path": str(bundle_dir), - "ui_bundle_path": str(bundle_dir), - "index_path": str(index_file), - "source": "builtin", - } - - -def _normalize_request_ui_path(request_path: str) -> str: - path = "/" + str(request_path or "").lstrip("/") - return path if path != "//" else "/" - - -def _is_custom_ui_static_asset_path(relative_path: str) -> bool: - path = str(relative_path or "").strip("/") - if not path: - return False - first_segment = path.split("/", 1)[0] - return first_segment == "assets" or bool(Path(path).suffix) - - -def _resolve_ui_static_response(request_path: str) -> Optional[FileResponse]: - spec = _resolve_agent_ui_spec() - if not spec.get("enabled"): - return None - - bundle_dir = Path(str(spec["bundle_path"])) - index_file = Path(str(spec["index_path"])) - path = _normalize_request_ui_path(request_path) - - if spec.get("source") == "custom": - ui_path = _normalize_request_ui_path(str(spec.get("path") or "/")).rstrip("/") or "/" - if path == ui_path or path == f"{ui_path}/": - return FileResponse(index_file) - if ui_path != "/" and not path.startswith(f"{ui_path}/"): - return None - relative = path[len(ui_path) :].lstrip("/") if ui_path != "/" else path.lstrip("/") - if not relative: - return FileResponse(index_file) - candidate = bundle_dir / relative - if candidate.exists() and candidate.is_file(): - return FileResponse(candidate) - if not _is_custom_ui_static_asset_path(relative): - return FileResponse(index_file) - return None - - if path in _RESERVED_UI_PATHS: - return FileResponse(index_file) - - candidate = bundle_dir / path.lstrip("/") - if candidate.exists() and candidate.is_file(): - return FileResponse(candidate) - return None - - -def _is_textual_mime(mime_type: str) -> bool: - mime = (mime_type or "").lower() - if not mime: - return False - return mime.startswith(_TEXT_MIME_PREFIXES) or mime in _TEXT_MIME_TYPES - - -def _looks_like_textual_attachment(mime_type: str, display_name: str) -> bool: - suffix = Path(display_name or "").suffix.lower() - return _is_textual_mime(mime_type) or suffix in _TEXT_FILE_EXTENSIONS - - -def _extract_pdf_text(raw: bytes) -> str: - try: - from pypdf import PdfReader - except Exception: - return "" - - try: - reader = PdfReader(io.BytesIO(raw)) - except Exception: - return "" - - segments: List[str] = [] - for page in reader.pages[:10]: - try: - page_text = page.extract_text() or "" - except Exception: - page_text = "" - if page_text: - segments.append(page_text) - - return "\n".join(segments).strip() - - -def _decode_inline_data(data_b64: str) -> bytes: - return base64.b64decode((data_b64 or "").strip() + "===") - - -def _resolve_uploads_dir() -> Path: - uploads_dir = resolve_local_session_dir() / "files" - uploads_dir.mkdir(parents=True, exist_ok=True) - return uploads_dir - - -def _resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: - normalized_uri = (file_uri or "").strip() - if not normalized_uri: - return None - - if normalized_uri.startswith("local:"): - path = Path(normalized_uri[6:]).expanduser() - return path.resolve() - - if normalized_uri.startswith(_UPLOAD_URI_SCHEME): - file_id = normalized_uri.removeprefix(_UPLOAD_URI_SCHEME).strip("/") - if not file_id: - return None - - for candidate in sorted(_resolve_uploads_dir().glob(f"{file_id}*")): - if candidate.is_file(): - return candidate.resolve() - - return None - - -def _custom_api_proxy_base_url() -> str: - for key in _CUSTOM_API_PROXY_ENV_KEYS: - value = os.environ.get(key) - if value and value.strip(): - return value.strip().rstrip("/") - return "" - - -def _proxy_headers(headers: Mapping[str, str]) -> dict[str, str]: - return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS} - - -def _response_headers(headers: Mapping[str, str]) -> dict[str, str]: - return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS} - - -@app.api_route( - "/api/{proxy_path:path}", - methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - include_in_schema=False, ) -async def custom_api_proxy(proxy_path: str, request: Request): - base_url = _custom_api_proxy_base_url() - if not base_url: - raise HTTPException(status_code=404, detail="Custom API backend is not configured") - - path = f"/api/{proxy_path.lstrip('/')}" - query = request.url.query - target_url = f"{base_url}{path}" - if query: - target_url = f"{target_url}?{query}" - try: - async with httpx.AsyncClient(timeout=60.0) as client: - upstream = await client.request( - request.method, - target_url, - content=await request.body(), - headers=_proxy_headers(request.headers), - ) - except httpx.HTTPError as exc: - raise HTTPException( - status_code=502, detail=f"Custom API backend unavailable: {exc}" - ) from exc - - return Response( - content=upstream.content, - status_code=upstream.status_code, - headers=_response_headers(upstream.headers), - media_type=upstream.headers.get("content-type"), - ) - - -def _read_attachment_bytes( - storage_path: Optional[Path], *, size_limit: Optional[int] = None -) -> Optional[bytes]: - if storage_path is None or not storage_path.is_file(): - return None - - try: - if size_limit is not None and storage_path.stat().st_size > size_limit: - return None - return storage_path.read_bytes() - except OSError: - return None - - -def _extract_inline_attachment_text(*, display_name: str, mime_type: str, raw: bytes) -> str: - if mime_type == "application/pdf" or display_name.lower().endswith(".pdf"): - text = _extract_pdf_text(raw) - if not text: - return "" - if len(text) > _MAX_INLINE_TEXT_CHARS: - return text[:_MAX_INLINE_TEXT_CHARS] + "\n...[内容已截断]" - return text - - if _looks_like_textual_attachment(mime_type, display_name): - text = raw.decode("utf-8", errors="ignore") - if len(text) > _MAX_INLINE_TEXT_CHARS: - return text[:_MAX_INLINE_TEXT_CHARS] + "\n...[内容已截断]" - return text - - return "" - - -def _attachment_prompt_text(attachment: Dict[str, Any]) -> str: - display_name = str(attachment.get("display_name") or "uploaded_file") - mime_type = str(attachment.get("mime_type") or "application/octet-stream") - transport = str(attachment.get("transport") or "") - - if transport == "inline": - data_b64 = str(attachment.get("data") or "").strip() - if len(data_b64) > _MAX_INLINE_BASE64_CHARS: - return ( - f"[上传文件: {display_name}, mime={mime_type or 'unknown'}, 内容过大,未直接展开]" - ) - - try: - raw = _decode_inline_data(data_b64) - except Exception: - return f"[上传文件: {display_name}, 内容解码失败]" - - text = _extract_inline_attachment_text( - display_name=display_name, - mime_type=mime_type, - raw=raw, - ) - if text: - return f"[上传文件: {display_name}]\n{text}" - return ( - "[上传文件: " - f"{display_name}, " - f"mime={mime_type or 'application/octet-stream'}, " - f"bytes={len(raw)}]" - ) - - storage_path_value = attachment.get("storage_path") - storage_path = Path(str(storage_path_value)) if storage_path_value else None - size_bytes = attachment.get("size_bytes") - if size_bytes is None and storage_path is not None and storage_path.exists(): - try: - size_bytes = storage_path.stat().st_size - except OSError: - size_bytes = None - - raw = _read_attachment_bytes(storage_path, size_limit=_MAX_REFERENCE_TEXT_BYTES) - if raw is not None: - text = _extract_inline_attachment_text( - display_name=display_name, - mime_type=mime_type, - raw=raw, - ) - if text: - return f"[上传文件: {display_name}]\n{text}" - return ( - "[上传文件: " - f"{display_name}, " - f"mime={mime_type or 'application/octet-stream'}, " - f"bytes={len(raw)}]" - ) - - if size_bytes and size_bytes > _MAX_REFERENCE_TEXT_BYTES: - return ( - "[上传文件: " - f"{display_name}, " - f"mime={mime_type or 'unknown'}, " - f"bytes={size_bytes}, " - "内容过大,未直接展开]" - ) - - file_uri = attachment.get("file_uri") or "" - return f"[上传文件引用: {display_name or file_uri}, mime={mime_type or 'unknown'}]" - - -def _extract_user_input_from_parts(parts: List[Any]) -> str: - """兼容旧测试/旧调用点,统一复用 conversations 层的规范化逻辑。""" - - return conversation.extract_user_input_from_parts(parts) - - -def _attachment_from_part(part: Any) -> Optional[Dict[str, Any]]: - """兼容旧入口,真实实现已经收口到 conversations.normalize。""" - - return conversation.attachment_from_part(part) - - -async def _hydrate_session(session: Optional[Session]) -> Optional[Session]: - if not session: - return None - session.events = await resolve_session_service().get_events(session.id) - return session - - -async def _ensure_session(agent_id: str, user_id: str, session_id: Optional[str]) -> Session: - service = resolve_session_service() - if session_id: - existing = await service.get_session(session_id) - if existing: - if existing.agent_id != agent_id or existing.user_id != user_id: - raise HTTPException( - status_code=409, - detail="Session id belongs to a different agent or user", - ) - return await _hydrate_session(existing) or existing - created = await service.create_session(agent_id, user_id, session_id=session_id) - return await _hydrate_session(created) or created - - created = await service.create_session(agent_id, user_id) - return await _hydrate_session(created) or created - - -def _sanitize_session_state_for_action(state: Mapping[str, Any] | None) -> dict[str, Any]: - sanitized = dict(state or {}) - attachment_context = sanitized.get(conversation.runtime.ATTACHMENT_CONTEXT_STATE_KEY) - if not isinstance(attachment_context, Mapping): - return sanitized - - attachments = [ - conversation.compact_attachment_for_session(item) - for item in attachment_context.get("attachments") or [] - if isinstance(item, dict) - ] - attachment_results = [ - compact_attachment_result_for_session(item) - for item in attachment_context.get("attachment_results") or [] - if isinstance(item, dict) - ] - sanitized[conversation.runtime.ATTACHMENT_CONTEXT_STATE_KEY] = { - "attachments": attachments, - "attachment_results": attachment_results, - } - return sanitized - - -def _request_id() -> str: - return f"req-{uuid.uuid4().hex[:12]}" - - -def _action_response( - action: str, data: Any, *, request_id: Optional[str] = None, message: str = "Success" -) -> dict: - payload = { - "Code": 0, - "Message": message, - "RequestId": request_id or _request_id(), - "Data": data, - } - if action: - payload["Action"] = action - return payload - - -async def _workspace_runtime_request( - method: str, - runtime_path: str, - *, - params: Optional[Dict[str, Any]] = None, - files: Optional[Dict[str, Any]] = None, -) -> httpx.Response: - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: - response = await client.request( - method, - runtime_path, - params=params, - files=files, - ) - - if response.status_code >= 400: - detail = response.text - try: - payload = response.json() - except Exception: - payload = None - if isinstance(payload, dict): - detail = str(payload.get("detail") or detail) - raise HTTPException( - status_code=response.status_code, detail=detail or "Workspace request failed" - ) - return response - - -# ============================================================ -# Core ADK API Endpoints -# ============================================================ - - -@app.get("/health") -async def health_check(): - 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 - return {"status": "ok", "framework": framework, "agent": agent_name} - - -@app.get("/list-apps") -async def list_apps(relative_path: str = "./"): - """Return available apps. For KsADK single-agent mode, returns the current agent.""" - name = runner.detection_result.name if runner else "default_agent" - return [name] - - -class UiBootstrapRequest(BaseModel): - AgentId: Optional[str] = None - SessionId: Optional[str] = None - - -class CreateSessionActionRequest(BaseModel): - AgentId: str - UserId: Optional[str] = "user" - SessionId: Optional[str] = None - - -class ListSessionsActionRequest(BaseModel): - AgentId: str - UserId: Optional[str] = "user" - Page: int = Field(1, ge=1) - PageSize: int = Field(20, ge=1, le=200) - - -class SessionIdRequest(BaseModel): - SessionId: str - - -class ListSessionEventsActionRequest(BaseModel): - SessionId: str - Offset: Optional[int] = Field(None, ge=0) - Limit: Optional[int] = Field(None, ge=1) - AfterSeqId: Optional[int] = Field(None, ge=0) - BeforeSeqId: Optional[int] = Field(None, ge=1) - - -class ListSessionMessagesActionRequest(BaseModel): - SessionId: str - AfterSeqId: Optional[int] = Field(None, ge=0) - BeforeSeqId: Optional[int] = Field(None, ge=1) - Limit: int = Field(50, ge=1, le=200) - IncludeReasoning: bool = False - IncludeToolEvents: bool = False - IncludeAttachments: bool = True - - -class ListSessionCheckpointsActionRequest(BaseModel): - AgentId: str - SessionId: str - RunId: Optional[str] = None - OnlyResumable: bool = False - Framework: Optional[str] = None - Offset: Optional[int] = Field(None, ge=0) - Limit: Optional[int] = Field(None, ge=1, le=500) - - -class ListToolReceiptsActionRequest(BaseModel): - AgentId: str - SessionId: str - RunId: Optional[str] = None - CheckpointId: Optional[str] = None - - -class ResumeRunActionRequest(BaseModel): - AgentId: str - SessionId: str - RunId: str - CheckpointId: str - ResumeAttemptId: Optional[str] = None - InvocationId: Optional[str] = None - Stream: bool = False - Model: Optional[str] = None - ModelMetadata: Optional[Dict[str, Any]] = None - ModelOptions: Optional[Dict[str, Any]] = None - ResumeInstructionEnabled: bool = False - ResumeInstruction: Optional[str] = None - - -class GetCheckpointResumePreviewActionRequest(BaseModel): - AgentId: str - SessionId: str - RunId: str - CheckpointId: str - - -class RunAgentActionRequest(BaseModel): - AgentId: str - Messages: List[Dict[str, Any]] = Field(default_factory=list) - UserId: Optional[str] = "user" - AccountId: Optional[str] = None - SessionId: Optional[str] = None - InvocationId: Optional[str] = None - ApiFormat: str = "responses" - Stream: bool = False - Background: bool = False # 立即返回 job 句柄,后台执行,进度走 SubscribeRunEvents - Model: Optional[str] = None - ModelMetadata: Optional[Dict[str, Any]] = None - ModelOptions: Optional[Dict[str, Any]] = None - ResponsesInput: Optional[Any] = None - PreviousResponseId: Optional[str] = None - - -class ResponseFeedbackRefActionRequest(BaseModel): - AgentId: str - SessionId: str - ResponseId: str - - -class UpsertResponseFeedbackActionRequest(ResponseFeedbackRefActionRequest): - Rating: str - Comment: Optional[str] = "" - EventId: Optional[str] = None - TraceId: Optional[str] = None - RootSpanId: Optional[str] = None - - -class ResponsesRequest(BaseModel): - input: Any - model: Optional[str] = None - model_metadata: Optional[Dict[str, Any]] = None - model_options: Optional[Dict[str, Any]] = None - instructions: Optional[str] = None - metadata: Optional[Dict[str, Any]] = None - conversation: Optional[Any] = None - safety_identifier: Optional[str] = None - prompt_cache_key: Optional[str] = None - user: Optional[str] = None - account_id: Optional[str] = None - store: Optional[bool] = None - previous_response_id: Optional[str] = None - stream: bool = False - session_id: Optional[str] = None - - -class WorkspaceListActionRequest(BaseModel): - AgentId: Optional[str] = None - Path: str = "." - Recursive: bool = False - - -def _clean_optional_string(value: Any) -> str | None: - if value is None: - return None - text = str(value).strip() - return text or None - - -def _resolve_responses_conversation_id(conversation_value: Any) -> str | None: - if conversation_value is None: - return None - if isinstance(conversation_value, str): - return _clean_optional_string(conversation_value) - if isinstance(conversation_value, Mapping): - return _clean_optional_string(conversation_value.get("id")) - raise HTTPException( - status_code=400, - detail="Responses field 'conversation' must be a string or an object with an 'id'.", - ) - - -def _resolve_responses_session_and_user(request: ResponsesRequest) -> tuple[str | None, str]: - conversation_id = _resolve_responses_conversation_id(request.conversation) - legacy_session_id = _clean_optional_string(request.session_id) - - if conversation_id and legacy_session_id and conversation_id != legacy_session_id: - raise HTTPException( - status_code=400, - detail=( - "Responses field 'conversation' conflicts with ksadk legacy field " - "'session_id'. Use 'conversation' for OpenAI-compatible calls." - ), - ) - if conversation_id and request.previous_response_id: - raise HTTPException( - status_code=400, - detail=( - "Responses fields 'conversation' and 'previous_response_id' cannot be " - "used together." - ), - ) - - resolved_session_id = conversation_id or legacy_session_id - resolved_user_id = ( - _clean_optional_string(request.safety_identifier) - or _clean_optional_string(request.user) - or "user" - ) - return resolved_session_id, resolved_user_id - - -def _runtime_agent_id(active_runner: BaseRunner) -> 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") - - -def _metadata_invocation_id(metadata: Mapping[str, Any] | None) -> str | None: - if not isinstance(metadata, Mapping): - return None - agentengine_metadata = metadata.get("agentengine") - if not isinstance(agentengine_metadata, Mapping): - return None - return _clean_optional_string(agentengine_metadata.get("invocation_id")) - - -def _event_text(event: SessionEvent) -> str: - parts = (event.content or {}).get("parts") - if isinstance(parts, list): - text_parts: list[str] = [] - for part in parts: - if isinstance(part, Mapping): - text_parts.append(str(part.get("text") or "")) - else: - text_parts.append(str(part or "")) - text = "".join(text_parts).strip() - if text: - return text - return str((event.content or {}).get("text") or "").strip() - - -def _truncate_session_text(text: str, limit: int = 512) -> str: - normalized = " ".join(str(text or "").strip().split()) - if len(normalized) <= limit: - return normalized - return f"{normalized[: max(limit - 1, 0)].rstrip()}…" - - -def _session_user_prompt_from_event(event: SessionEvent) -> str: - metadata = event.metadata or {} - content = event.content or {} - return str( - metadata.get("agent_input") - or metadata.get("user_input") - or content.get("agent_input") - or _event_text(event) - or "" - ).strip() - - -def _run_status_payload_status(event: SessionEvent) -> str: - return str( - (event.metadata or {}).get("status") - or (event.metadata or {}).get("run_status") - or (event.content or {}).get("status") - or "" - ).strip() - - -def _event_run_id(event: SessionEvent) -> str: - return str( - (event.metadata or {}).get("run_id") - or (event.metadata or {}).get("invocation_id") - or event.invocation_id - or "" - ).strip() - - -def _session_topic_from_events(events: list[SessionEvent]) -> str: - for event in reversed(events): - metadata = event.metadata or {} - tool_output = metadata.get("tool_output") - if isinstance(tool_output, Mapping): - topic = str(tool_output.get("topic") or tool_output.get("research_title") or "").strip() - if topic: - return topic - topic = str(metadata.get("research_title") or metadata.get("task_title") or "").strip() - if topic: - return topic - return "" - - -def _latest_session_run_status(events: list[SessionEvent]) -> tuple[str, str]: - latest_by_invocation: dict[str, tuple[str, SessionEvent]] = {} - for event in reversed(events): - if event.event_type != "run_status": - continue - status = _run_status_payload_status(event) - invocation_id = _event_run_id(event) - if status or invocation_id: - latest_by_invocation.setdefault(invocation_id, (status, event)) - for invocation_id, (status, _) in latest_by_invocation.items(): - if status in _RUN_ACTIVE_STATUSES: - return invocation_id, status - for invocation_id, (status, _) in latest_by_invocation.items(): - if status not in _RUN_TERMINAL_STATUSES: - return invocation_id, status - if latest_by_invocation: - invocation_id, (status, _) = next(iter(latest_by_invocation.items())) - return invocation_id, status - for event in reversed(events): - invocation_id = _event_run_id(event) - if invocation_id: - return invocation_id, "" - return "", "" - - -def _latest_session_run_metadata( - events: list[SessionEvent], -) -> tuple[str, str, str, str]: - """返回 (invocation_id, status, run_mode, run_trigger)。 - - 与 _latest_session_run_status 同语义,但额外从最新 run_status 事件的 metadata - 读取 run_mode/run_trigger。旧事件缺字段降级 unknown。原 _latest_session_run_status - 不动,保护现有 ActiveInvocationId/ActiveRunStatus 契约。 - """ - invocation_id, status = _latest_session_run_status(events) - run_mode = RUN_MODE_UNKNOWN - run_trigger = RUN_TRIGGER_UNKNOWN - if invocation_id: - for event in reversed(events): - if event.event_type != "run_status" 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 - return invocation_id, status, run_mode, run_trigger - - -class WorkspaceDeleteActionRequest(BaseModel): - AgentId: Optional[str] = None - Path: str - - -class CancelRunActionRequest(BaseModel): - AgentId: Optional[str] = None - InvocationId: str - - -async def _session_to_action_payload(session: Session) -> dict[str, Any]: - events = list(session.events or []) - if not events: - try: - events = await resolve_session_service().get_events(session.id) - except Exception as exc: - logger.debug("Failed to hydrate events for session %s: %s", session.id, exc) - events = [] - event_prompts = [ - _session_user_prompt_from_event(event) - for event in events - if event.event_type == "user_message" - ] - event_prompts = [prompt for prompt in event_prompts if prompt] - first_prompt = session.first_prompt or (event_prompts[0] if event_prompts else "") - last_prompt = session.last_prompt or (event_prompts[-1] if event_prompts else "") - ( - active_invocation_id, - active_run_status, - active_run_mode, - active_run_trigger, - ) = _latest_session_run_metadata(events) - title = session.title - title_source = session.title_source - if not title: - title_seed = _session_topic_from_events(events) or first_prompt - if title_seed: - title = build_fallback_title(title_seed) - title_source = "fallback_first_prompt" - if title_source == "fallback_first_prompt": - heuristic = build_heuristic_title( - first_prompt=first_prompt or title, - assistant_text=session.summary or "", - ) - if heuristic and heuristic != title: - title = heuristic - title_source = HEURISTIC_SESSION_TITLE_SOURCE - payload = { - "SessionId": session.id, - "AgentId": session.agent_id, - "UserId": session.user_id, - "Title": title, - "TitleSource": title_source, - "Summary": session.summary, - "FirstPrompt": _truncate_session_text(first_prompt), - "LastPrompt": _truncate_session_text(last_prompt), - "ActiveInvocationId": active_invocation_id, - "ActiveRunStatus": active_run_status, - "ActiveRunMode": active_run_mode, - "ActiveRunTrigger": active_run_trigger, - "State": _sanitize_session_state_for_action(session.state), - "CreatedAt": session.created_at, - "UpdatedAt": session.updated_at, - "Version": session.version, - } - if runner is not None: - try: - continuity = await runner.get_session_adapter().describe_continuity( - runner=runner, - session=session, - core=ConversationSessionCore(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 _event_to_action_payload(event: SessionEvent) -> dict[str, Any]: - payload = { - "EventId": event.id, - "SessionId": event.session_id, - "Author": event.author, - "EventType": event.event_type, - "Content": event.content, - "Timestamp": event.timestamp, - "SeqId": event.seq_id, - "Metadata": event.metadata, - } - if event.invocation_id: - payload["InvocationId"] = event.invocation_id - return payload - - -def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: - if event.event_type != "run_checkpoint": - 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() - framework_ref = metadata.get("framework_ref") - if not run_id or not checkpoint_id or not framework or not isinstance(framework_ref, Mapping): - return None - next_node = str(metadata.get("next_node") or "").strip() - if not next_node: - langgraph_ref = framework_ref.get("langgraph") - if isinstance(langgraph_ref, Mapping): - next_node = str(langgraph_ref.get("next_node") or "").strip() - is_terminal = bool(metadata.get("is_terminal", False)) - is_resumable_raw = metadata.get("is_resumable") - is_resumable = is_resumable_raw if isinstance(is_resumable_raw, bool) else None - backend = str(metadata.get("backend") or "unknown").strip() or "unknown" - scope = str(metadata.get("scope") or "unknown").strip() or "unknown" - durable = bool(metadata.get("durable", False)) - disabled_reason = str(metadata.get("resume_disabled_reason") or "").strip() - if is_terminal: - is_resumable = False - disabled_reason = disabled_reason or "该 checkpoint 已是终态;可选择更早恢复点重跑" - if backend == "memory" or scope == "process_local": - is_resumable = False - disabled_reason = disabled_reason or "进程内 checkpoint 不能跨实例恢复" - resume_status = str(metadata.get("resume_status") or "").strip() - if not resume_status: - if is_resumable is True: - resume_status = "resumable" - elif is_resumable is False: - resume_status = "disabled" - else: - resume_status = "unknown" - if resume_status == "disabled" and not disabled_reason: - disabled_reason = "该 checkpoint 不可恢复" - artifact_preview = metadata.get("artifact_preview") - if not isinstance(artifact_preview, Mapping): - artifact_preview = {} - resume_count_raw = metadata.get("resume_count") - try: - resume_count = int(resume_count_raw) if resume_count_raw is not None else 0 - except (TypeError, ValueError): - resume_count = 0 - last_resumed_at = metadata.get("last_resumed_at") - replay_allowed_raw = metadata.get("replay_allowed") - replay_allowed = replay_allowed_raw if isinstance(replay_allowed_raw, bool) else True - expires_at = metadata.get("expires_at") - checkpoint_status = str(metadata.get("checkpoint_status") or "").strip() - if not checkpoint_status: - if is_terminal: - checkpoint_status = "resumed" if resume_count else "terminal" - elif is_resumable is False: - checkpoint_status = "disabled" - else: - checkpoint_status = "active" - payload = { - "EventId": event.id, - "SessionId": event.session_id, - "InvocationId": event.invocation_id, - "SeqId": event.seq_id, - "Timestamp": event.timestamp, - "RunId": run_id, - "CheckpointId": checkpoint_id, - "Framework": framework, - "FrameworkRef": dict(framework_ref), - "Phase": str(metadata.get("phase") or ""), - "Metadata": metadata, - "IsResumable": is_resumable, - "ResumeStatus": resume_status, - "IsTerminal": is_terminal, - "ResumeDisabledReason": disabled_reason, - "NextNode": next_node, - "StageKey": str(metadata.get("stage_key") or ""), - "StageName": str( - metadata.get("stage_name") or metadata.get("stage") or metadata.get("title") or "" - ), - "StageIndex": metadata.get("stage_index"), - "TotalStages": metadata.get("total_stages"), - "Backend": backend, - "Scope": scope, - "Durable": durable, - "CreatedAt": event.timestamp, - "ArtifactPreview": dict(artifact_preview), - "LastResumedAt": last_resumed_at, - "ResumeCount": resume_count, - "ReplayAllowed": replay_allowed, - "ExpiresAt": expires_at, - "CheckpointStatus": checkpoint_status, - } - stage = str(metadata.get("stage") or metadata.get("title") or "").strip() - summary = str(metadata.get("summary") or metadata.get("description") or "").strip() - next_action = str(metadata.get("next_action") or metadata.get("nextAction") or "").strip() - status = str(metadata.get("status") or "").strip() - if stage: - payload["Stage"] = stage - if summary: - payload["Summary"] = summary - if next_action: - payload["NextAction"] = next_action - if status: - payload["Status"] = status - return payload - - -def _resume_audit_by_checkpoint( - events: list[SessionEvent], -) -> dict[tuple[str, str], dict[str, Any]]: - audit: dict[tuple[str, str], dict[str, Any]] = {} - for event in events: - if event.event_type != "run_resume": - continue - metadata = event.metadata or {} - run_id = str(metadata.get("run_id") or "").strip() - checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() - if not run_id or not checkpoint_id: - continue - key = (run_id, checkpoint_id) - item = audit.setdefault(key, {"resume_count": 0, "last_resumed_at": None}) - item["resume_count"] = int(item["resume_count"]) + 1 - item["last_resumed_at"] = event.timestamp - return audit - - -def _apply_checkpoint_resume_audit( - checkpoint: dict[str, Any], - audit_by_checkpoint: Mapping[tuple[str, str], Mapping[str, Any]], -) -> dict[str, Any]: - audit = audit_by_checkpoint.get( - ( - str(checkpoint.get("RunId") or ""), - str(checkpoint.get("CheckpointId") or ""), - ), - {}, - ) - metadata = dict(checkpoint.get("Metadata") or {}) - resume_count = int(audit.get("resume_count") or checkpoint.get("ResumeCount") or 0) - last_resumed_at = audit.get("last_resumed_at") or checkpoint.get("LastResumedAt") - checkpoint["ResumeCount"] = resume_count - checkpoint["LastResumedAt"] = last_resumed_at - metadata["resume_count"] = resume_count - metadata["last_resumed_at"] = last_resumed_at - if resume_count and checkpoint.get("CheckpointStatus") in {"", "active"}: - checkpoint["CheckpointStatus"] = "resumed" - expires_at = checkpoint.get("ExpiresAt") - expires_at_dt = _parse_iso_datetime(expires_at) - if expires_at_dt is not None and expires_at_dt <= datetime.now(timezone.utc): - checkpoint["IsResumable"] = False - checkpoint["ResumeStatus"] = "disabled" - checkpoint["CheckpointStatus"] = "expired" - checkpoint["ResumeDisabledReason"] = "该 checkpoint 已过期" - elif resume_count and checkpoint.get("ReplayAllowed") is False: - checkpoint["IsResumable"] = False - checkpoint["ResumeStatus"] = "disabled" - checkpoint["ResumeDisabledReason"] = "该 checkpoint 已恢复过,当前策略不允许重复恢复" - metadata["checkpoint_status"] = checkpoint.get("CheckpointStatus") - metadata["resume_status"] = checkpoint.get("ResumeStatus") - metadata["resume_disabled_reason"] = checkpoint.get("ResumeDisabledReason") - checkpoint["Metadata"] = metadata - return checkpoint - - -def _apply_adk_only_latest_resumable( - checkpoints: list[dict[str, Any]], -) -> list[dict[str, Any]]: - """P1.4: For ADK invocation_id resume mode, only the latest checkpoint per - RunId is independently resumable. Older checkpoints get IsResumable=False.""" - latest_by_run: dict[str, int] = {} - for cp in checkpoints: - metadata = cp.get("Metadata") or {} - if not metadata.get("only_latest_resumable"): - continue - run_id = str(cp.get("RunId") or "") - seq_id = int(cp.get("SeqId") or 0) - if run_id not in latest_by_run or seq_id > latest_by_run[run_id]: - latest_by_run[run_id] = seq_id - - for cp in checkpoints: - metadata = cp.get("Metadata") or {} - if not metadata.get("only_latest_resumable"): - continue - run_id = str(cp.get("RunId") or "") - seq_id = int(cp.get("SeqId") or 0) - if seq_id < latest_by_run.get(run_id, 0): - if cp.get("IsResumable") is True: - cp["IsResumable"] = False - cp["ResumeStatus"] = "disabled" - cp["ResumeDisabledReason"] = ( - "新的恢复点已生成,此恢复点暂停恢复能力" - ) - metadata["resume_disabled_reason"] = ( - "新的恢复点已生成,此恢复点暂停恢复能力" - ) - metadata["resume_status"] = "disabled" - cp["Metadata"] = metadata - - return checkpoints - - -def _check_adk_latest_resumable( - checkpoint: dict[str, Any], - events: list, -) -> dict[str, Any]: - """P1.4: For a single ADK only_latest_resumable checkpoint, verify it is - the latest for its RunId. If not, mark IsResumable=False.""" - metadata = checkpoint.get("Metadata") or {} - if not metadata.get("only_latest_resumable"): - return checkpoint - - run_id = str(checkpoint.get("RunId") or "") - my_seq_id = int(checkpoint.get("SeqId") or 0) - - max_seq_id = my_seq_id - for event in events: - if event.event_type != "run_checkpoint": - continue - ev_meta = event.metadata or {} - if str(ev_meta.get("run_id") or "") != run_id: - continue - seq_id = int(event.seq_id or 0) - if seq_id > max_seq_id: - max_seq_id = seq_id - - if my_seq_id < max_seq_id and checkpoint.get("IsResumable") is True: - checkpoint["IsResumable"] = False - checkpoint["ResumeStatus"] = "disabled" - checkpoint["ResumeDisabledReason"] = ( - "新的恢复点已生成,此恢复点暂停恢复能力" - ) - metadata["resume_disabled_reason"] = ( - "新的恢复点已生成,此恢复点暂停恢复能力" - ) - metadata["resume_status"] = "disabled" - checkpoint["Metadata"] = metadata - - return checkpoint - - -_SIDE_EFFECT_TOOL_NAMES = { - "write_workspace_file", - "write_workspace_files", - "delete_workspace_file", - "execute_skills", - "run_command", - "run_code", -} - - -def _tool_receipt_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: - if event.event_type != "tool_result": - return None - metadata = event.metadata or {} - receipt = metadata.get("tool_receipt") - if not isinstance(receipt, Mapping): - return None - tool_name = str(receipt.get("tool_name") or metadata.get("tool_name") or "").strip() - if not tool_name: - return None - return { - "EventId": event.id, - "SessionId": event.session_id, - "InvocationId": event.invocation_id, - "SeqId": event.seq_id, - "Timestamp": event.timestamp, - "ReceiptId": str(receipt.get("receipt_id") or ""), - "IdempotencyKey": str(receipt.get("idempotency_key") or ""), - "ToolName": tool_name, - "ToolCallId": str(receipt.get("tool_call_id") or ""), - "RunId": str(receipt.get("run_id") or metadata.get("run_id") or ""), - "CheckpointId": str(receipt.get("checkpoint_id") or ""), - "Status": str(receipt.get("status") or ""), - "Replayed": bool(receipt.get("replayed") or metadata.get("replayed")), - "Metadata": dict(metadata), - } - - -def _build_checkpoint_resume_preview( - *, - checkpoint: Mapping[str, Any], - events: list[SessionEvent], -) -> dict[str, Any]: - checkpoint_seq_id = int(checkpoint.get("SeqId") or 0) - run_id = str(checkpoint.get("RunId") or "") - receipts: list[dict[str, Any]] = [] - for event in events: - if checkpoint_seq_id and int(event.seq_id or 0) > checkpoint_seq_id: - continue - receipt = _tool_receipt_event_to_action_payload(event) - if receipt is None: - continue - if run_id and receipt["RunId"] and receipt["RunId"] != run_id: - continue - receipts.append(receipt) - - side_effect_receipts = [ - receipt for receipt in receipts if receipt["ToolName"] in _SIDE_EFFECT_TOOL_NAMES - ] - risk_level = "low" - if side_effect_receipts: - risk_level = "medium" - if any(receipt["Status"] == "failed" for receipt in receipts): - risk_level = "high" - - return { - "Checkpoint": dict(checkpoint), - "Capabilities": { - "Checkpoints": True, - "CheckpointResume": checkpoint.get("IsResumable") is not False, - "ToolReceipts": True, - "IdempotentToolReplay": True, - }, - "CanResume": checkpoint.get("IsResumable") is not False, - "Reason": str(checkpoint.get("ResumeDisabledReason") or ""), - "NextNode": str(checkpoint.get("NextNode") or ""), - "ExpectedAction": ( - "resume_from_checkpoint" - if checkpoint.get("IsResumable") is True - else ("preview_required" if checkpoint.get("ResumeStatus") == "unknown" else "disabled") - ), - "ToolReceipts": receipts, - "Risk": { - "Level": risk_level, - "DuplicateSideEffectRisk": bool(side_effect_receipts), - "SideEffectReceiptCount": len(side_effect_receipts), - "FailedReceiptCount": len( - [receipt for receipt in receipts if receipt["Status"] == "failed"] - ), - }, - "Summary": { - "RunId": run_id, - "CheckpointId": str(checkpoint.get("CheckpointId") or ""), - "Phase": str(checkpoint.get("Phase") or ""), - "ToolReceiptCount": len(receipts), - }, - } - - -def _checkpoint_resume_disabled_detail(checkpoint: Mapping[str, Any]) -> dict[str, Any] | None: - if checkpoint.get("IsResumable") is not False: - return None - reason = ( - str(checkpoint.get("ResumeDisabledReason") or "").strip() or "Checkpoint is not resumable" - ) - return { - "code": "checkpoint_not_resumable", - "reason": reason, - "checkpoint_id": str(checkpoint.get("CheckpointId") or ""), - "run_id": str(checkpoint.get("RunId") or ""), - "resume_status": str(checkpoint.get("ResumeStatus") or "disabled"), - "is_terminal": bool(checkpoint.get("IsTerminal")), - } - - -async def _find_session_checkpoint( - *, - service: Any, - session_id: str, - run_id: str, - checkpoint_id: str, -) -> dict[str, Any] | None: - events = await service.get_events(session_id) - resume_audit = _resume_audit_by_checkpoint(events) - for event in reversed(events): - checkpoint = _checkpoint_event_to_action_payload(event) - if checkpoint is None: - continue - checkpoint = _apply_checkpoint_resume_audit(checkpoint, resume_audit) - if checkpoint["RunId"] != run_id: - continue - if checkpoint["CheckpointId"] != checkpoint_id: - continue - checkpoint = _check_adk_latest_resumable(checkpoint, events) - return checkpoint - return None - - -async def _resolve_checkpoint_resume_input_from_session( - *, - service: Any, - agent_id: str, - session_id: str | None, - resume_input: Mapping[str, Any] | None, -) -> dict[str, Any] | None: - if not isinstance(resume_input, Mapping): - return None - if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint": - return dict(resume_input) - normalized_session_id = str(session_id or "").strip() - if not normalized_session_id: - raise HTTPException(status_code=400, detail="Checkpoint resume requires session_id") - - session = await service.get_session(normalized_session_id) - if not session or session.agent_id != agent_id: - raise HTTPException(status_code=404, detail="Session not found") - - 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 HTTPException( - status_code=400, detail="Checkpoint resume requires run_id and checkpoint_id" - ) - - checkpoint = await _find_session_checkpoint( - service=service, - session_id=normalized_session_id, - run_id=run_id, - checkpoint_id=checkpoint_id, - ) - if checkpoint is None: - raise HTTPException(status_code=404, detail="Checkpoint not found") - - resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip() - return { - "type": "agentengine.resume_checkpoint", - "run_id": run_id, - "checkpoint_id": checkpoint_id, - "resume_attempt_id": resume_attempt_id or f"resume_{uuid.uuid4().hex}", - "framework": checkpoint["Framework"], - "framework_ref": checkpoint["FrameworkRef"], - "metadata": dict(checkpoint.get("Metadata") or {}), - "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}), - "resume_instruction_enabled": bool( - resume_input.get("resume_instruction_enabled") - or resume_input.get("ResumeInstructionEnabled") - ), - "resume_instruction": str( - resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or "" - ).strip(), - } - - -def _feedback_state_key(response_id: str) -> str: - return str(response_id or "").strip() - - -def _feedback_payload_from_state(item: Mapping[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(item, Mapping): - return None - rating = str(item.get("Rating") or item.get("rating") or "").strip().lower() - if rating not in {"up", "down"}: - return None - return { - "AgentId": str(item.get("AgentId") or item.get("agent_id") or ""), - "SessionId": str(item.get("SessionId") or item.get("session_id") or ""), - "ResponseId": str(item.get("ResponseId") or item.get("response_id") or ""), - "EventId": str(item.get("EventId") or item.get("event_id") or ""), - "Rating": rating, - "Comment": str(item.get("Comment") or item.get("comment") or ""), - "TraceId": str(item.get("TraceId") or item.get("trace_id") or ""), - "RootSpanId": str(item.get("RootSpanId") or item.get("root_span_id") or ""), - "CreatedAt": str(item.get("CreatedAt") or item.get("created_at") or ""), - "UpdatedAt": str(item.get("UpdatedAt") or item.get("updated_at") or ""), - } - - -async def _find_feedback_assistant_event( - *, - session_id: str, - response_id: str, - event_id: str | None = None, -) -> SessionEvent | None: - events = await resolve_session_service().get_events(session_id) - normalized_event_id = str(event_id or "").strip() - normalized_response_id = str(response_id or "").strip() - for event in reversed(events): - if normalized_event_id and event.id != normalized_event_id: - continue - metadata = event.metadata or {} - if ( - normalized_response_id - and str(metadata.get("response_id") or "") != normalized_response_id - ): - continue - event_type = conversation.canonical_event_type( - event.event_type, - author=event.author, - role=str((event.content or {}).get("role") or ""), - ) - if event_type == "assistant_message": - return event - return None - - -@app.post("/agentengine/api/v1/GetResponseFeedback") -async def get_response_feedback_action(request: ResponseFeedbackRefActionRequest): - session = await resolve_session_service().get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - return _action_response("GetResponseFeedback", {"Feedback": None}) - feedbacks = session.state.get("__ksadk_response_feedback__") - feedback = None - if isinstance(feedbacks, Mapping): - feedback = _feedback_payload_from_state( - feedbacks.get(_feedback_state_key(request.ResponseId)) - ) - return _action_response("GetResponseFeedback", {"Feedback": feedback}) - - -@app.post("/agentengine/api/v1/UpsertResponseFeedback") -async def upsert_response_feedback_action(request: UpsertResponseFeedbackActionRequest): - rating = str(request.Rating or "").strip().lower() - if rating not in {"up", "down"}: - raise HTTPException(status_code=400, detail="Feedback rating must be up or down") - - service = resolve_session_service() - session = await service.get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - raise HTTPException(status_code=404, detail="Session not found") - - assistant_event = await _find_feedback_assistant_event( - session_id=request.SessionId, - response_id=request.ResponseId, - event_id=request.EventId, - ) - if assistant_event is None: - raise HTTPException(status_code=404, detail="Assistant response not found") - - now = str(time.time()) - existing_feedbacks = session.state.get("__ksadk_response_feedback__") - feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {} - existing = ( - _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) or {} - ) - metadata = assistant_event.metadata or {} - feedback = { - "AgentId": request.AgentId, - "SessionId": request.SessionId, - "ResponseId": request.ResponseId, - "EventId": request.EventId or assistant_event.id, - "Rating": rating, - "Comment": request.Comment or "", - "TraceId": request.TraceId or str(metadata.get("trace_id") or ""), - "RootSpanId": request.RootSpanId or str(metadata.get("root_span_id") or ""), - "CreatedAt": existing.get("CreatedAt") or now, - "UpdatedAt": now, - } - feedbacks[_feedback_state_key(request.ResponseId)] = feedback - await service.update_state( - agent_id=session.agent_id, - user_id=session.user_id, - session_id=session.id, - scope="session", - state_delta={"__ksadk_response_feedback__": feedbacks}, - ) - return _action_response("UpsertResponseFeedback", {"Feedback": feedback}) - - -@app.post("/agentengine/api/v1/DeleteResponseFeedback") -async def delete_response_feedback_action(request: ResponseFeedbackRefActionRequest): - service = resolve_session_service() - session = await service.get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - return _action_response("DeleteResponseFeedback", {"Deleted": False}) - existing_feedbacks = session.state.get("__ksadk_response_feedback__") - feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {} - deleted = feedbacks.pop(_feedback_state_key(request.ResponseId), None) is not None - if deleted: - await service.update_state( - agent_id=session.agent_id, - user_id=session.user_id, - session_id=session.id, - scope="session", - state_delta={"__ksadk_response_feedback__": feedbacks}, - ) - return _action_response("DeleteResponseFeedback", {"Deleted": deleted}) - - -@app.post("/agentengine/api/v1/GetAgentUiBootstrap") -async def get_agent_ui_bootstrap(request: UiBootstrapRequest): - 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() - workspace_enabled = workspace_files_enabled(default=True) - ui_spec = _resolve_agent_ui_spec() - runtime_capabilities = ( - runner.get_runtime_capabilities() - if runner and callable(getattr(runner, "get_runtime_capabilities", None)) - 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 {}, - } - 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 - ) - return _action_response( - "GetAgentUiBootstrap", - { - "Agent": { - "AgentId": agent_id, - "Name": runner.detection_result.name if runner else agent_id, - "Description": description or "", - "Framework": framework, - }, - "Modules": ["Chat", "Build", "Deploy"], - "Capabilities": { - "Attachments": True, - "WorkspaceFiles": workspace_enabled, - "Approval": True, - "Thinking": True, - "StopRun": cancel_run_supported, - "ResumeRun": checkpoint_resume_supported, - "RuntimeCapabilities": runtime_capabilities, - "CheckpointResumeCapability": checkpoint_resume_capability, - "RunLifecycle": { - "Enabled": True, - "Resume": True, - "Abort": True, - "Checkpoints": checkpoint_resume_supported, - "CheckpointResume": checkpoint_resume_supported, - "CheckpointResumePreview": checkpoint_resume_supported, - }, - "MCP": False, - "HostedRuntime": False, - "NativeTerminal": _build_native_terminal_capability(framework), - "BuiltinTools": describe_agentengine_tools(), - }, - "WorkspaceFiles": build_workspace_files_bootstrap(enabled=workspace_enabled), - "AccessMode": "Owner", - "SharePermissions": { - "Interactive": True, - "DefaultPath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat", - "SharePath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat", - }, - "CustomUI": { - "Enabled": bool(ui_spec.get("enabled")), - "Profile": ui_spec.get("ui_profile") or ui_spec.get("profile"), - "Path": ui_spec.get("ui_path") or ui_spec.get("path"), - "Url": ui_spec.get("ui_url") or ui_spec.get("url"), - "BundlePath": ui_spec.get("ui_bundle_path") or ui_spec.get("bundle_path"), - }, - "ApiFormats": ["responses", "chat_completions"], - "Stream": True, - "SessionId": request.SessionId, - "SessionBackend": describe_session_backend(), - "HostedRuntime": None, - "Model": _build_bootstrap_model_payload(), - }, - ) - - -@app.post("/agentengine/api/v1/CreateSession") -async def create_session_action(request: CreateSessionActionRequest): - session = await _ensure_session(request.AgentId, request.UserId or "user", request.SessionId) - return _action_response("CreateSession", {"Session": await _session_to_action_payload(session)}) - - -@app.post("/agentengine/api/v1/ListSessions") -async def list_sessions_action(request: ListSessionsActionRequest): - service = resolve_session_service() - offset = (request.Page - 1) * request.PageSize - sessions = await service.list_sessions( - request.AgentId, - request.UserId or "user", - offset=offset, - limit=request.PageSize, - ) - total = await service.count_sessions(request.AgentId, request.UserId or "user") - session_payloads = [await _session_to_action_payload(session) for session in sessions] - return _action_response( - "ListSessions", - { - "Sessions": session_payloads, - "Total": total, - "Page": request.Page, - "PageSize": request.PageSize, - }, - ) - - -@app.post("/agentengine/api/v1/GetSession") -async def get_session_action(request: SessionIdRequest): - service = resolve_session_service() - session = await _hydrate_session(await service.get_session(request.SessionId)) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - return _action_response("GetSession", {"Session": await _session_to_action_payload(session)}) - - -@app.post("/agentengine/api/v1/DeleteSession") -async def delete_session_action(request: SessionIdRequest): - service = resolve_session_service() - await _cancel_detached_streams_for_session(request.SessionId) - deleted = await service.delete_session(request.SessionId) - if not deleted: - raise HTTPException(status_code=404, detail="Session not found") - return _action_response("DeleteSession", {"Deleted": True}) - - -@app.post("/agentengine/api/v1/ListSessionEvents") -async def list_session_events_action(request: ListSessionEventsActionRequest): - service = resolve_session_service() - events = await service.get_events( - request.SessionId, - offset=request.Offset, - limit=request.Limit, - after_seq_id=request.AfterSeqId, - before_seq_id=request.BeforeSeqId, - ) - total = await service.count_events( - request.SessionId, - after_seq_id=request.AfterSeqId, - before_seq_id=request.BeforeSeqId, - ) - return _action_response( - "ListSessionEvents", - { - "Events": [_event_to_action_payload(event) for event in events], - "Total": total, - "Offset": request.Offset or 0, - "Limit": request.Limit if request.Limit is not None else len(events), - "AfterSeqId": request.AfterSeqId, - "BeforeSeqId": request.BeforeSeqId, - }, - ) - - -@app.post("/agentengine/api/v1/ListSessionMessages") -async def list_session_messages_action(request: ListSessionMessagesActionRequest): - from ksadk.conversations.message_projection import project_session_messages - - service = resolve_session_service() - events = await service.get_events( - request.SessionId, - offset=0, - limit=2000, - after_seq_id=request.AfterSeqId, - before_seq_id=request.BeforeSeqId, - ) - serialized_events = [_event_to_action_payload(event) for event in events] - messages = project_session_messages( - serialized_events, - include_reasoning=request.IncludeReasoning, - include_tool_events=request.IncludeToolEvents, - include_attachments=request.IncludeAttachments, - ) - if request.AfterSeqId is not None: - page = messages - has_more = False - next_cursor = None - else: - page = messages[-request.Limit :] - minimum_seq_id = int(page[0].get("SeqId") or 0) if page else 0 - has_more = minimum_seq_id > 1 or len(serialized_events) >= 2000 - next_cursor = minimum_seq_id - 1 if has_more else None - latest_seq_id = ( - int(page[-1].get("SeqId") or 0) - if page - else max((int(event.get("SeqId") or 0) for event in serialized_events), default=0) - ) - return _action_response( - "ListSessionMessages", - { - "SessionId": request.SessionId, - "Messages": page, - "LatestSeqId": latest_seq_id, - "HasMore": has_more, - "NextCursor": next_cursor, - }, - ) - - -def _count_resumable_checkpoints(checkpoints: list[dict[str, Any]]) -> int: - """统计可恢复 checkpoint 数量。 - - 规则:IsResumable=True AND ReplayAllowed!=False AND IsTerminal!=True - AND CheckpointStatus not in {expired, disabled}。 - 不排除 resumed(已恢复过的仍计入,符合存档点可反复读的回档语义)。 - """ - resumable = 0 - for cp in checkpoints: - if cp.get("IsResumable") is not True: - continue - if cp.get("ReplayAllowed") is False: - continue - if cp.get("IsTerminal") is True: - continue - status = str(cp.get("CheckpointStatus") or "").strip().lower() - if status in {"expired", "disabled"}: - continue - resumable += 1 - return resumable - - -async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest) -> dict[str, Any]: - service = resolve_session_service() - session = await service.get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - raise HTTPException(status_code=404, detail="Session not found") - - run_id_filter = str(request.RunId or "").strip() - framework_filter = str(request.Framework or "").strip().lower() - events = await service.get_events(request.SessionId) - resume_audit = _resume_audit_by_checkpoint(events) - checkpoints: list[dict[str, Any]] = [] - for event in events: - checkpoint = _checkpoint_event_to_action_payload(event) - if checkpoint is None: - continue - checkpoint = _apply_checkpoint_resume_audit(checkpoint, resume_audit) - if run_id_filter and checkpoint["RunId"] != run_id_filter: - continue - if framework_filter and str(checkpoint["Framework"]).lower() != framework_filter: - continue - # ResumableTotal 在 OnlyResumable 过滤前统计全量可恢复数(RunId/Framework 范围内) - checkpoints.append(checkpoint) - checkpoints = _apply_adk_only_latest_resumable(checkpoints) - resumable_total = _count_resumable_checkpoints(checkpoints) - if request.OnlyResumable: - checkpoints = [cp for cp in checkpoints if cp.get("IsResumable") is True] - total = len(checkpoints) - offset = int(request.Offset or 0) - if request.Limit is not None: - checkpoints = checkpoints[offset : offset + int(request.Limit)] - elif offset: - checkpoints = checkpoints[offset:] - - return { - "Checkpoints": checkpoints, - "Total": total, - "ResumableTotal": resumable_total, - "HasResumableCheckpoint": resumable_total > 0, - "Offset": offset, - "Limit": request.Limit if request.Limit is not None else len(checkpoints), - } - - -@app.post("/agentengine/api/v1/ListSessionCheckpoints") -async def list_session_checkpoints_action(request: ListSessionCheckpointsActionRequest): - return _action_response("ListSessionCheckpoints", await _list_checkpoints_payload(request)) - - -@app.post("/agentengine/api/v1/ListToolReceipts") -async def list_tool_receipts_action(request: ListToolReceiptsActionRequest): - service = resolve_session_service() - session = await service.get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - raise HTTPException(status_code=404, detail="Session not found") - - run_id_filter = str(request.RunId or "").strip() - checkpoint_id_filter = str(request.CheckpointId or "").strip() - receipts: list[dict[str, Any]] = [] - for event in await service.get_events(request.SessionId): - receipt = _tool_receipt_event_to_action_payload(event) - if receipt is None: - continue - if run_id_filter and receipt["RunId"] != run_id_filter: - continue - if checkpoint_id_filter and receipt["CheckpointId"] != checkpoint_id_filter: - continue - receipts.append(receipt) - - return _action_response( - "ListToolReceipts", - {"ToolReceipts": receipts}, - ) - - -@app.post("/agentengine/api/v1/GetCheckpointResumePreview") -async def get_checkpoint_resume_preview_action(request: GetCheckpointResumePreviewActionRequest): - service = resolve_session_service() - session = await service.get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - raise HTTPException(status_code=404, detail="Session not found") - - events = await service.get_events(request.SessionId) - resume_audit = _resume_audit_by_checkpoint(events) - checkpoint = None - for event in reversed(events): - candidate = _checkpoint_event_to_action_payload(event) - if candidate is None: - continue - candidate = _apply_checkpoint_resume_audit(candidate, resume_audit) - if candidate["RunId"] != str(request.RunId): - continue - if candidate["CheckpointId"] != str(request.CheckpointId): - continue - checkpoint = candidate - break - if checkpoint is None: - raise HTTPException(status_code=404, detail="Checkpoint not found") - - checkpoint = _check_adk_latest_resumable(checkpoint, events) - - return _action_response( - "GetCheckpointResumePreview", - {"Preview": _build_checkpoint_resume_preview(checkpoint=checkpoint, events=events)}, - ) - - -@app.post("/agentengine/api/v1/ResumeRun") -async def resume_run_action(request: ResumeRunActionRequest): - service = resolve_session_service() - session = await service.get_session(request.SessionId) - if not session or session.agent_id != request.AgentId: - raise HTTPException(status_code=404, detail="Session not found") - - checkpoint = await _find_session_checkpoint( - service=service, - session_id=request.SessionId, - run_id=str(request.RunId), - checkpoint_id=str(request.CheckpointId), - ) - if checkpoint is None: - raise HTTPException(status_code=404, detail="Checkpoint not found") - disabled_detail = _checkpoint_resume_disabled_detail(checkpoint) - if disabled_detail is not None: - if disabled_detail.get("is_terminal"): - resume_attempt_id = str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}") - invocation_id = str(request.InvocationId or resume_attempt_id) - await conversation.append_run_resume_event( - session_id=request.SessionId, - author=request.AgentId, - run_id=str(request.RunId), - checkpoint_id=str(request.CheckpointId), - resume_attempt_id=resume_attempt_id, - framework=checkpoint["Framework"], - framework_ref=checkpoint["FrameworkRef"], - invocation_id=invocation_id, - session_service_provider=resolve_session_service, - ) - await conversation.append_run_status_event( - session_id=request.SessionId, - author=request.AgentId, - status="completed", - invocation_id=invocation_id, - detail="resume_noop_terminal_checkpoint", - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_BACKGROUND, - run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, - ) - return _action_response( - "ResumeRun", - { - "status": "noop", - "Reason": disabled_detail["reason"], - "CheckpointId": disabled_detail["checkpoint_id"], - "RunId": disabled_detail["run_id"], - "ResumeAttemptId": resume_attempt_id, - }, - ) - raise HTTPException(status_code=409, detail=disabled_detail) - - resume_input = { - "type": "agentengine.resume_checkpoint", - "run_id": str(request.RunId), - "checkpoint_id": str(request.CheckpointId), - "resume_attempt_id": str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}"), - "framework": checkpoint["Framework"], - "framework_ref": checkpoint["FrameworkRef"], - "metadata": dict(checkpoint.get("Metadata") or {}), - "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}), - "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" - - if request.Stream: - resume_invocation_id = str(request.InvocationId or resume_input["resume_attempt_id"]) - resume_key = _detached_resume_key_from_input(request.SessionId, resume_input) - _reject_if_detached_resume_active(resume_key) - return _detached_streaming_response( - conversation.stream_responses_conversation_turn( - runner=active_runner, - agent_id=request.AgentId, - user_id=user_id, - messages=[], - session_id=request.SessionId, - model=request.Model, - model_metadata=request.ModelMetadata, - model_options=request.ModelOptions, - request_metadata={"responses_conversation": True}, - resume_input=resume_input, - invocation_id=resume_invocation_id, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_BACKGROUND, - ), - invocation_id=resume_invocation_id, - resume_key=resume_key, - run_mode=RUN_MODE_BACKGROUND, - run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, - ) - - response_id = f"resp_{uuid.uuid4().hex}" - resolved_session_id, result = await conversation.invoke_conversation_once( - runner=active_runner, - agent_id=request.AgentId, - user_id=user_id, - messages=[], - session_id=request.SessionId, - model=request.Model, - model_metadata=request.ModelMetadata, - model_options=request.ModelOptions, - request_metadata={"responses_conversation": True}, - 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=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ) - payload = conversation.build_responses_payload( - output_text=result["output_text"], - model=request.Model, - session_id=resolved_session_id, - response_id=response_id, - metadata=result.get("metadata") if isinstance(result.get("metadata"), dict) else None, - ) - return _action_response("ResumeRun", payload) - - -@app.get("/agentengine/api/v1/SubscribeRunEvents", include_in_schema=False) -async def subscribe_run_events_action( - SessionId: str = Query(...), - InvocationId: str = Query(...), - AfterSeqId: int = Query(0), -): - session_id = str(SessionId or "").strip() - invocation_id = str(InvocationId or "").strip() - if not session_id or not invocation_id: - raise HTTPException(status_code=400, detail="SessionId and InvocationId are required") - - async def event_generator() -> AsyncIterator[str]: - service = resolve_session_service() - last_seq_id = int(AfterSeqId or 0) - deadline = time.monotonic() + 5 * 60 - while True: - # 增量查询:把 after_seq_id 下推到后端,只取 seq_id > last_seq_id 的事件, - # 避免每轮全量拉取。invocation_id 过滤仍在 Python 侧。 - events = await service.get_events(session_id, after_seq_id=last_seq_id) - matched_events = [ - event - for event in events - if event.invocation_id == invocation_id - ] - for event in matched_events: - last_seq_id = max(last_seq_id, event.seq_id) - payload = _event_to_action_payload(event) - yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" - if ( - event.event_type == "run_status" - and str((event.content or {}).get("status") or "").strip().lower() - in _RUN_TERMINAL_STATUSES - ): - yield "data: [DONE]\n\n" - return - - # 重连兜底:本轮无新事件时,查全量确认 run 是否已有 terminal - # (客户端断连期间 run 已结束)。 - # 正常流式期间不触发此查询,保持增量收益。 - if not matched_events: - all_events = await service.get_events(session_id) - latest_status = None - for event in all_events: - if event.invocation_id != invocation_id or event.event_type != "run_status": - continue - latest_status = str((event.content or {}).get("status") or "").strip().lower() - if latest_status in _RUN_TERMINAL_STATUSES: - yield "data: [DONE]\n\n" - return - if time.monotonic() > deadline: - return - await asyncio.sleep(0.25) - - return StreamingResponse(event_generator(), media_type="text/event-stream") - - -@app.post("/agentengine/api/v1/UploadFile") -async def upload_file_action(file: UploadFile = File(...)): - file_id = uuid.uuid4().hex - data = await file.read() - file_uri, _local_path = await AttachmentStorageService().store( - data=data, - file_id=file_id, - display_name=file.filename, - mime_type=file.content_type, - ) - - return _action_response( - "UploadFile", - { - "FileData": { - "fileUri": file_uri, - "displayName": file.filename or "uploaded_file", - "mimeType": file.content_type or "application/octet-stream", - "sizeBytes": len(data), - } - }, - ) - - -@app.get("/agentengine/api/v1/AttachmentContent", include_in_schema=False) -async def attachment_content_action(FileUri: str = Query(...)): - loaded = AttachmentStorageService().read(FileUri) - if loaded is None: - raise HTTPException(status_code=404, detail="Attachment not found") - - return Response( - content=loaded.data, - media_type=loaded.mime_type or "application/octet-stream", - headers={"Content-Disposition": f'inline; filename="{loaded.display_name}"'}, - ) - - -@app.post("/agentengine/api/v1/ListWorkspaceFiles") -async def list_workspace_files_action(request: WorkspaceListActionRequest): - response = await _workspace_runtime_request( - "GET", - "/_ksadk/workspace/v1/entries", - params={ - "path": request.Path, - "recursive": "true" if request.Recursive else "false", - }, - ) - return _action_response("ListWorkspaceFiles", response.json()) - - -@app.post("/agentengine/api/v1/AddWorkspaceFile") -async def upload_workspace_file_action( - file: UploadFile = File(...), - AgentId: Optional[str] = Form(None), - Path: str = Form(...), -): - del AgentId - try: - payload = await file.read() - finally: - await file.close() - - file_name = file.filename or Path.rsplit("/", 1)[-1] - response = await _workspace_runtime_request( - "POST", - f"/_ksadk/workspace/v1/files/{quote(Path, safe='/')}", - files={ - "file": ( - file_name, - payload, - file.content_type or "application/octet-stream", - ) - }, - ) - return _action_response("AddWorkspaceFile", response.json()) - - -@app.post("/agentengine/api/v1/DeleteWorkspaceFile") -async def delete_workspace_file_action(request: WorkspaceDeleteActionRequest): - response = await _workspace_runtime_request( - "DELETE", - f"/_ksadk/workspace/v1/files/{quote(request.Path, safe='/')}", - ) - return _action_response("DeleteWorkspaceFile", response.json()) - - -@app.post("/agentengine/api/v1/CancelRun") -async def cancel_run_action(request: CancelRunActionRequest): - detached = _DETACHED_STREAMS_BY_INVOCATION.get(request.InvocationId) - found = detached 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: - 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" - 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 - return _action_response( - "CancelRun", - { - "Cancelled": bool(cancel_requested or runner_accepted), - "Found": found, - "Status": status, - "RunnerCancelStatus": runner_cancel_status, - }, - ) - - -@app.get("/agentengine/api/v1/GetWorkspaceFileContent", include_in_schema=False) -async def get_workspace_file_content_action( - FilePath: str = Query(...), - AgentId: Optional[str] = Query(None), -): - del AgentId - response = await _workspace_runtime_request( - "GET", - f"/_ksadk/workspace/v1/files/{quote(FilePath, safe='/')}", - ) - headers = {} - for key in ("content-disposition", "last-modified"): - value = response.headers.get(key) - if value: - headers[key] = value - return Response( - content=response.content, - status_code=response.status_code, - headers=headers, - media_type=response.headers.get("content-type"), - ) - - -@app.get("/agentengine/api/v1/ws/{agent_id}/{file_path:path}", include_in_schema=False) -async def workspace_file_path_route(request: Request, agent_id: str, file_path: str): - response = await _workspace_runtime_request( - "GET", - f"/_ksadk/workspace/v1/files/{quote(file_path, safe='/')}", - ) - headers = {} - for key in ("content-disposition", "last-modified"): - value = response.headers.get(key) - if value: - headers[key] = value - - content_type = response.headers.get("content-type", "") - is_html = "text/html" in content_type or file_path.lower().endswith((".html", ".htm")) - - if is_html and response.status_code == 200: - del agent_id - base_href = build_workspace_file_base_href(file_path) - asset_source = f"{request.url.scheme}://{request.url.netloc}{base_href}" - html_doc = response.content.decode("utf-8", errors="replace") - html_doc = inject_workspace_html_preview(html_doc, file_path) - headers.pop("content-disposition", None) - headers["Content-Security-Policy"] = build_workspace_preview_csp(asset_source) - return Response( - content=html_doc.encode("utf-8"), - status_code=response.status_code, - headers=headers, - media_type="text/html; charset=utf-8", - ) - - return Response( - content=response.content, - status_code=response.status_code, - headers=headers, - media_type=content_type, - ) - - -@app.get("/agentengine/api/v1/ExportWorkspaceZip", include_in_schema=False) -async def export_workspace_zip( - AgentId: Optional[str] = Query(None), - Path: str = Query("."), -): - del AgentId - dir_path = Path.strip() or "." - response = await _workspace_runtime_request( - "GET", - "/_ksadk/workspace/v1/entries", - params={"path": dir_path, "recursive": "true"}, - ) - data = response.json() if response.status_code == 200 else {} - entries = data.get("Entries", []) if isinstance(data, dict) else [] - root = _workspace_root_dir() - root_resolved = root.resolve() - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - for entry in entries: - if entry.get("Type") != "file": - continue - rel = entry.get("Path", "") - if not rel: - continue - rel_path = PurePosixPath(rel) - if rel_path.is_absolute() or ".." in rel_path.parts: - continue - target = root.joinpath(*rel_path.parts) - if target.is_symlink(): - continue - try: - resolved_target = target.resolve(strict=True) - except OSError: - continue - if not resolved_target.is_relative_to(root_resolved): - continue - if resolved_target.is_file(): - zf.writestr(rel_path.as_posix(), resolved_target.read_bytes()) - buf.seek(0) - zip_name = f"workspace-{dir_path.replace('/', '-')}.zip" if dir_path != "." else "workspace.zip" - return StreamingResponse( - buf, - media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, - ) - - -def _normalize_model_catalog_items(raw_models: list[Any]) -> list[dict[str, Any]]: - """统一模型目录 shape,并按 id 去重。 - - 这里刻意保留上游原始 dict 字段,再补 canonical metadata。 - 这样两周后模型服务扩展字段时,这一层不会再次把信息裁掉。 - """ - - normalized_by_id: dict[str, dict[str, Any]] = {} - for raw_model in raw_models: - item = normalize_model_metadata(raw_model) - normalized_by_id[item["id"]] = item - return sorted(normalized_by_id.values(), key=lambda item: item["id"]) - - -async def _build_models_payload() -> dict[str, Any]: - import os - - import httpx - - api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") - api_key = os.getenv("OPENAI_API_KEY") - current_model, source = _resolve_current_model() - - def _fallback_catalog() -> dict[str, Any]: - models = _normalize_model_catalog_items([current_model]) if current_model else [] - return { - "data": models, - "current": current_model, - "source": source, - } - - if not api_base: - return _fallback_catalog() - - try: - base_url = api_base.rstrip("/") - if base_url.endswith("/v1"): - url = f"{base_url}/models" - else: - url = f"{base_url}/v1/models" - - headers = {} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - - async with httpx.AsyncClient(verify=False, timeout=10) as client: - resp = await client.get(url, headers=headers) - resp.raise_for_status() - data = resp.json() - - if isinstance(data, list): - models = _normalize_model_catalog_items(list(data)) - else: - models = _normalize_model_catalog_items(list(data.get("data", []))) - if current_model and all( - 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} - except Exception as e: - logger.error(f"Failed to fetch models: {e}") - fallback = _fallback_catalog() - fallback["error"] = str(e) - return fallback - - -class ListAgentModelsRequest(BaseModel): - AgentId: Optional[str] = None - Name: Optional[str] = None - - -@app.post("/agentengine/api/v1/ListAgentModels") -async def list_agent_models_action(_request: ListAgentModelsRequest): - payload = await _build_models_payload() - return _action_response( - "ListAgentModels", - { - "Models": payload.get("data", []), - "Current": payload.get("current"), - "Source": payload.get("source", ""), - }, - ) - - -@app.get("/v1/models") -async def list_openai_models(): - """Expose the current model catalog through the OpenAI-compatible path.""" - - payload = await _build_models_payload() - return { - "object": "list", - "data": payload.get("data", []), - "current": payload.get("current"), - "source": payload.get("source", ""), - } - - -@app.post("/agentengine/api/v1/RunAgent") -async def run_agent_action(request: RunAgentActionRequest): - 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 = resolve_session_service() - resume_input = ( - conversation.extract_responses_resume_input(request.ResponsesInput) - if request.ResponsesInput is not None - else None - ) - resume_input = await _resolve_checkpoint_resume_input_from_session( - service=service, - agent_id=request.AgentId, - session_id=request.SessionId, - resume_input=resume_input, - ) - if resume_input is not None: - messages = [] - elif request.ResponsesInput is not None and api_format == "responses": - messages = conversation.normalize_responses_input(request.ResponsesInput) - else: - messages = conversation.normalize_kop_messages(request.Messages) - request_metadata = ( - {"previous_response_id": request.PreviousResponseId} if request.PreviousResponseId else {} - ) - if api_format == "responses": - request_metadata["responses_conversation"] = True - - if request.Background: - invocation_id = request.InvocationId or f"inv_{uuid.uuid4().hex}" - # 后台 stream 在 detached task 里才被消费(lazy),此时 session 尚未创建。 - # 先 ensure 出 session,才能立刻写 run_status=in_progress(供 SubscribeRunEvents - # 拉到起始态),并把 resolved session_id 回填给 detached stream 的终态写入与 SubscribeUrl。 - background_session = await conversation.ensure_conversation_session( - agent_id=request.AgentId, - user_id=run_user_id, - session_id=request.SessionId, - session_service_provider=resolve_session_service, - ) - resolved_background_session_id = background_session.id - if resume_input is None: - await conversation.prime_session_metadata_for_user_turn( - service=service, - session=background_session, - messages=messages, - ) - await conversation.append_run_status_event( - session_id=resolved_background_session_id, - author=_resolve_active_runner().detection_result.name, - status="in_progress", - invocation_id=invocation_id, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_BACKGROUND, - run_trigger=trigger_from_resume_input(resume_input), - ) - resume_key = _detached_resume_key_from_input(resolved_background_session_id, resume_input) - _reject_if_detached_resume_active(resume_key) - detached = _DetachedSSEStream( - conversation.stream_responses_conversation_turn( - runner=_resolve_active_runner(), - agent_id=request.AgentId, - user_id=run_user_id, - messages=messages, - session_id=resolved_background_session_id, - model=request.Model, - model_metadata=request.ModelMetadata, - model_options=request.ModelOptions, - request_metadata=request_metadata or None, - resume_input=resume_input, - account_id=account_id, - invocation_id=invocation_id, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_BACKGROUND, - ), - invocation_id=invocation_id, - session_id=resolved_background_session_id, - run_mode=RUN_MODE_BACKGROUND, - run_trigger=trigger_from_resume_input(resume_input), - ) - if invocation_id and resume_key: - _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key - _ACTIVE_DETACHED_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(inv, rk) - ) - return _action_response( - "RunAgent", - { - "SessionId": resolved_background_session_id, - "InvocationId": invocation_id, - "Status": "running", - "Background": True, - "SubscribeUrl": ( - "/agentengine/api/v1/SubscribeRunEvents" - f"?SessionId={resolved_background_session_id}" - f"&InvocationId={invocation_id}" - ), - }, - ) - - if request.Stream: - if api_format == "chat_completions": - completion_request = ChatCompletionRequest( - messages=messages, - model=request.Model, - model_metadata=request.ModelMetadata, - model_options=request.ModelOptions, - stream=True, - session_id=request.SessionId, - user=run_user_id, - account_id=account_id, - ) - return await chat_completions(completion_request) - resume_key = _detached_resume_key_from_input(request.SessionId, resume_input) - _reject_if_detached_resume_active(resume_key) - return _detached_streaming_response( - conversation.stream_responses_conversation_turn( - runner=_resolve_active_runner(), - agent_id=request.AgentId, - user_id=run_user_id, - messages=messages, - session_id=request.SessionId, - model=request.Model, - model_metadata=request.ModelMetadata, - model_options=request.ModelOptions, - request_metadata=request_metadata or None, - resume_input=resume_input, - account_id=account_id, - invocation_id=request.InvocationId, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ), - invocation_id=request.InvocationId, - resume_key=resume_key, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=trigger_from_resume_input(resume_input), - ) - - responses_response_id = f"resp_{uuid.uuid4().hex}" if api_format != "chat_completions" else None - resolved_session_id, result = await conversation.invoke_conversation_once( - runner=_resolve_active_runner(), - agent_id=request.AgentId, - user_id=run_user_id, - messages=messages, - session_id=request.SessionId, - model=request.Model, - model_metadata=request.ModelMetadata, - model_options=request.ModelOptions, - request_metadata=request_metadata or None, - resume_input=resume_input, - response_id=responses_response_id, - account_id=account_id, - invocation_id=request.InvocationId, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ) - output_text = result["output_text"] - if api_format == "chat_completions": - payload = conversation.build_chat_completions_payload( - output_text=output_text, - model=request.Model, - session_id=resolved_session_id, - metadata=result.get("metadata"), - ) - else: - payload = conversation.build_responses_payload( - output_text=output_text, - model=request.Model, - session_id=resolved_session_id, - response_id=responses_response_id, - metadata=result.get("metadata") - if isinstance(result.get("metadata"), Mapping) - else None, - ) - return _action_response("RunAgent", payload) - - -# ============================================================ -# Session Management API (ADK Web Compatible) -# ============================================================ - - -@app.post("/apps/{app_name}/users/{user_id}/sessions") -async def create_session(app_name: str, user_id: str, request: Request): - """Create a new session""" - # Check if importing existing events - body = {} - try: - body = await request.json() - except Exception: - pass - - service = resolve_session_service() - session = await _ensure_session(app_name, user_id, body.get("sessionId") or body.get("id")) - - for raw_event in body.get("events", []): - session_event = SessionEvent.from_dict(raw_event, session_id=session.id) - await service.append_event(session.id, session_event) - - hydrated = await _hydrate_session(await service.get_session(session.id)) - return hydrated.to_legacy_dict() if hydrated else session.to_legacy_dict() - - -@app.get("/apps/{app_name}/users/{user_id}/sessions") -async def list_sessions(app_name: str, user_id: str): - """List all sessions for a user""" - service = resolve_session_service() - sessions = await service.list_sessions(app_name, user_id) - hydrated: List[Dict[str, Any]] = [] - for session in sessions: - session.events = await service.get_events(session.id) - hydrated.append(session.to_legacy_dict()) - return hydrated - - -@app.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}") -async def get_session(app_name: str, user_id: str, session_id: str): - """Get a specific session with its events""" - service = resolve_session_service() - session = await _hydrate_session(await service.get_session(session_id)) - if not session: - raise HTTPException(status_code=404, detail="Session not found") - return session.to_legacy_dict() - - -@app.delete("/apps/{app_name}/users/{user_id}/sessions/{session_id}") -async def delete_session(app_name: str, user_id: str, session_id: str): - """Delete a session""" - service = resolve_session_service() - if await service.delete_session(session_id): - return {"status": "deleted"} - raise HTTPException(status_code=404, detail="Session not found") - - -# ============================================================ -# Memory API - Save session to long-term memory -# ============================================================ - - -@app.post("/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, - ) - - if success: - return {"status": "saved", "session_id": session_id} - else: - raise HTTPException(status_code=500, detail="Failed to save session to long-term memory") - - -# ============================================================ -# Run SSE - Core Agent Execution Endpoint -# ============================================================ - - -@app.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) - """ - active_runner = _ensure_runner_loaded() - _prepare_runner_for_model(active_runner, request.model) - use_streaming = request.streaming - normalized_message = conversation.normalize_parts_content( - request.newMessage.parts if request.newMessage else [] - ) - user_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 []), - } - - model_version = "models/gemini-pro" if "gemini" in request.appName.lower() else "models/unknown" - prepared_non_stream: conversation.PreparedConversationTurn | None = None - if request.sessionId: - await conversation.ensure_conversation_session( - agent_id=request.appName, - user_id=request.userId, - session_id=request.sessionId, - session_service_provider=resolve_session_service, - ) - if not use_streaming: - prepared_non_stream = await 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=resolve_session_service, - ) - await 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=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - - async def event_generator(): - if not use_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 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=resolve_session_service, - ) - await conversation.append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="completed", - invocation_id=invocation_id, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - - except Exception as e: - logger.error(f"Error in invoke: {e}") - await conversation.append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="failed", - invocation_id=invocation_id, - detail=str(e), - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - error_event = { - "id": str(uuid.uuid4()), - "sessionId": session_id, - "invocationId": invocation_id, - "error": str(e), - "errorMessage": str(e), - "timestamp": int(time.time() * 1000), - } - yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" - else: - try: - compaction_preview = await conversation.preview_auto_compaction( - agent_id=request.appName, - user_id=request.userId, - session_id=request.sessionId, - messages=[user_message], - session_service_provider=resolve_session_service, - ) - if compaction_preview.should_compact: - yield conversation.build_compaction_sse_event( - phase="start", - trigger="auto", - total_chars=compaction_preview.total_chars, - group_count=compaction_preview.group_count, - ) - - prepared = await 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=resolve_session_service, - ) - if prepared.compaction_triggered: - yield 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 = 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 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=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, - } - ) - while True: - try: - chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=15) - except StopAsyncIteration: - break - except asyncio.TimeoutError: - 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 - ) - continue - if chunk.get("type") == "thinking": - delta = str(chunk.get("delta", "")) - if delta: - await conversation.append_reasoning_event( - session_id=session_id, - author=active_runner.detection_result.name, - text=delta, - invocation_id=invocation_id, - session_service_provider=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", {}), - }, - ensure_ascii=False, - ) - + "\n\n" - ) - tool_event = { - "id": event_id, - "author": chunk.get("node", "tool"), - "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"], - "timestamp": int(time.time() * 1000), - } - yield f"data: {json.dumps(tool_event, ensure_ascii=False)}\n\n" - await 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=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 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=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 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=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 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=resolve_session_service, - ) - await conversation.append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="completed", - invocation_id=invocation_id, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - - except Exception as e: - logger.error(f"Error in stream: {e}") - await conversation.append_run_status_event( - session_id=session_id, - author=active_runner.detection_result.name, - status="failed", - invocation_id=invocation_id, - detail=str(e), - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=RUN_TRIGGER_NEW_RUN, - ) - error_event = { - "id": str(uuid.uuid4()), - "sessionId": session_id, - "invocationId": invocation_id, - "error": str(e), - "errorMessage": str(e), - "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") - - -# ============================================================ -# Trace / Debug API (ADK Web Compatible) -# ============================================================ - - -@app.get("/debug/trace/session/{session_id}") -async def get_session_trace(session_id: str): - """Get traces for a session - returns array of Span objects""" - exporter = get_memory_exporter() - if not exporter: - return [] # Return empty array, not object - - # Get all spans and transform to ADK-Web expected format - raw_spans = exporter.get_finished_spans() - - # Get session events for invocation mapping - service = resolve_session_service() - events = await service.get_events(session_id) - - # Build invocation ID mapping from session events - invocation_ids = {} - for event in events: - if event.id and event.invocation_id: - invocation_ids[event.id] = event.invocation_id - - # Transform spans to ADK-Web format - spans = [] - for span in raw_spans: - # Use session_id as trace_id for grouping - trace_id = span.get("trace_id", session_id) - - # Get or create invocation_id - invocation_id = span.get("attributes", {}).get("gcp.vertex.agent.invocation_id") - if not invocation_id: - # Try to derive from event association - invocation_id = trace_id[:36] if len(trace_id) >= 36 else trace_id - - # Build attributes with required ADK fields - attrs = span.get("attributes", {}).copy() - attrs["gcp.vertex.agent.invocation_id"] = invocation_id - - # If this is a LLM span, add request/response - if "llm" in span.get("name", "").lower() or "invoke" in span.get("name", "").lower(): - if "user.input" in attrs: - attrs["gcp.vertex.agent.llm_request"] = json.dumps( - { - "contents": [ - {"role": "user", "parts": [{"text": attrs.get("user.input", "")}]} - ] - } - ) - if "agent.output" in attrs: - attrs["gcp.vertex.agent.llm_response"] = json.dumps( - { - "candidates": [ - { - "content": { - "role": "model", - "parts": [{"text": attrs.get("agent.output", "")}], - } - } - ] - } - ) - - formatted_span = { - "trace_id": trace_id, - "span_id": span.get("span_id", str(uuid.uuid4())[:16]), - "parent_span_id": span.get("parent_span_id"), - "name": span.get("name", "unknown"), - "start_time": span.get("start_time", 0), - "end_time": span.get("end_time", 0), - "attributes": attrs, - "status": span.get("status", {}), - } - spans.append(formatted_span) - - return spans # Return array directly - - -@app.get("/debug/trace/{event_id}") -async def get_event_trace(event_id: str): - """Get trace for a specific event - returns array of Span objects""" - exporter = get_memory_exporter() - if not exporter: - return [] - - spans = exporter.get_finished_spans() - # Filter by event_id or return recent spans - filtered = [s for s in spans if s.get("attributes", {}).get("event_id") == event_id] - return filtered if filtered else spans[-10:] - - -@app.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}/events/{event_id}/graph") -async def get_event_graph(app_name: str, user_id: str, session_id: str, event_id: str): - """Get event graph (DOT format) - placeholder""" - return {"dotSrc": None} - - -# ============================================================ -# OpenAI Compatible API -# ============================================================ - - -class ChatCompletionRequest(BaseModel): - messages: List[Dict[str, Any]] - model: Optional[str] = None - model_metadata: Optional[Dict[str, Any]] = None - model_options: Optional[Dict[str, Any]] = None - stream: bool = False - session_id: Optional[str] = None - user: Optional[str] = None - account_id: Optional[str] = None - temperature: Optional[float] = 0.7 - max_tokens: Optional[int] = None - - -@app.post("/v1/responses") -async def responses(request: ResponsesRequest): - """OpenAI Responses 兼容接口。""" - active_runner = _resolve_active_runner() - resolved_session_id, resolved_user_id = _resolve_responses_session_and_user(request) - agent_id = _runtime_agent_id(active_runner) - - resume_input = conversation.extract_responses_resume_input(request.input) - resume_input = await _resolve_checkpoint_resume_input_from_session( - service=resolve_session_service(), - agent_id=agent_id, - session_id=resolved_session_id, - resume_input=resume_input, - ) - messages = ( - [] if resume_input is not None else conversation.normalize_responses_input(request.input) - ) - request_metadata = dict(request.metadata or {}) - if request.previous_response_id: - request_metadata.setdefault("previous_response_id", request.previous_response_id) - if request.prompt_cache_key: - request_metadata.setdefault("prompt_cache_key", request.prompt_cache_key) - if request.safety_identifier: - request_metadata.setdefault("safety_identifier", request.safety_identifier) - if request.user: - request_metadata.setdefault("user", request.user) - if request.conversation is not None: - request_metadata.setdefault("conversation", request.conversation) - if request.store is not None: - request_metadata.setdefault("store", request.store) - account_id = _clean_optional_string(request.account_id) - invocation_id = _metadata_invocation_id(request_metadata) - - if request.stream: - resume_key = _detached_resume_key_from_input(resolved_session_id, resume_input) - _reject_if_detached_resume_active(resume_key) - return _detached_streaming_response( - conversation.stream_responses_conversation_turn( - runner=active_runner, - agent_id=agent_id, - user_id=resolved_user_id, - messages=messages, - session_id=resolved_session_id, - model=request.model, - model_metadata=request.model_metadata, - model_options=request.model_options, - instructions=request.instructions, - request_metadata=request_metadata, - resume_input=resume_input, - account_id=account_id, - invocation_id=invocation_id, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ), - invocation_id=invocation_id, - resume_key=resume_key, - run_mode=RUN_MODE_FOREGROUND, - run_trigger=trigger_from_resume_input(resume_input), - ) - - response_id = f"resp_{uuid.uuid4().hex}" - resolved_session_id, result = await conversation.invoke_conversation_once( - runner=active_runner, - agent_id=agent_id, - user_id=resolved_user_id, - messages=messages, - session_id=resolved_session_id, - model=request.model, - model_metadata=request.model_metadata, - model_options=request.model_options, - instructions=request.instructions, - request_metadata=request_metadata, - resume_input=resume_input, - response_id=response_id, - account_id=account_id, - invocation_id=invocation_id, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ) - return conversation.build_responses_payload( - output_text=result["output_text"], - model=request.model, - session_id=resolved_session_id, - response_id=response_id, - metadata=result.get("metadata") - if isinstance(result.get("metadata"), dict) - else request_metadata, - ) - - -@app.post("/v1/chat/completions") -async def chat_completions(request: ChatCompletionRequest): - """OpenAI 兼容的聊天补全接口 (支持流式和非流式)""" - active_runner = _resolve_active_runner() - messages = conversation.normalize_kop_messages(request.messages) - agent_id = _runtime_agent_id(active_runner) - resolved_user_id = _clean_optional_string(request.user) or "user" - account_id = _clean_optional_string(request.account_id) - - if request.stream: - return StreamingResponse( - conversation.stream_conversation_turn( - runner=active_runner, - agent_id=agent_id, - user_id=resolved_user_id, - messages=messages, - session_id=request.session_id, - model=request.model, - model_metadata=request.model_metadata, - model_options=request.model_options, - account_id=account_id, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ), - media_type="text/event-stream", - ) - - resolved_session_id, result = await conversation.invoke_conversation_once( - runner=active_runner, - agent_id=agent_id, - user_id=resolved_user_id, - messages=messages, - session_id=request.session_id, - model=request.model, - model_metadata=request.model_metadata, - model_options=request.model_options, - account_id=account_id, - prepare_runner=_prepare_runner_for_model, - session_service_provider=resolve_session_service, - run_mode=RUN_MODE_FOREGROUND, - ) - return conversation.build_chat_completions_payload( - output_text=result["output_text"], - model=request.model, - session_id=resolved_session_id, - metadata=result.get("metadata"), - ) - - -# ============================================================ -# Stub Endpoints for ADK-Web Compatibility -# ============================================================ - - -@app.get("/apps/{app_name}/eval_sets") -async def list_eval_sets(app_name: str): - """List evaluation sets - stub for ADK-Web""" - return [] - - -@app.get("/apps/{app_name}/eval_results") -async def list_eval_results(app_name: str): - """List evaluation results - stub for ADK-Web""" - return [] - - -@app.get("/builder/app/{app_name}") -async def get_agent_builder(app_name: str, ts: int = 0, tmp: bool = False, file_path: str = None): - """Get agent builder config - stub for ADK-Web""" - # Return minimal YAML config for non-ADK projects - return f"""name: {app_name} -model: glm-5.1 -description: {app_name} agent -instruction: You are a helpful assistant. -""" - - -@app.post("/builder/save") -async def save_agent_builder(request: Request, tmp: bool = False): - """Save agent builder config - stub for ADK-Web""" - return True - - -@app.post("/builder/app/{app_name}/cancel") -async def cancel_agent_changes(app_name: str): - """Cancel agent builder changes - stub for ADK-Web""" - return True - - -# Legacy /traces endpoint -@app.get("/traces") -async def get_traces(limit: int = 50): - """Get recent traces (OpenTelemetry)""" - exporter = get_memory_exporter() - if not exporter: - return {"traces": []} - - spans = exporter.get_finished_spans() - traces = [] - for span in spans[-limit:]: - traces.append( - { - "name": span.get("name", "unknown"), - "status": span.get("status", {}).get("code", "UNSET"), - "start_time": span.get("start_time"), - "end_time": span.get("end_time"), - "attributes": span.get("attributes", {}), - } - ) - return {"traces": traces} - - -# ============================================================ -STATIC_DIR = Path(__file__).parent / "static" +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 -@app.get("/{requested_path:path}", include_in_schema=False) -async def serve_agent_ui_static(requested_path: str): - response = _resolve_ui_static_response(requested_path) - if response is not None: - return response - raise HTTPException(status_code=404, detail="Not Found") +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}") diff --git a/ksadk/server/composition.py b/ksadk/server/composition.py new file mode 100644 index 00000000..4bbab4cc --- /dev/null +++ b/ksadk/server/composition.py @@ -0,0 +1,21 @@ +"""Runtime app route composition independent from the default app facade.""" + +from fastapi import FastAPI + +from ksadk.server.factory import RuntimeAppState +from ksadk.server.routes.common import _register_integrated_routers +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() + _register_integrated_routers(app, state) + + ordered = sorted(group for group in groups if group != "health_meta") + if "health_meta" in groups: + ordered.append("health_meta") + for group in ordered: + router = GROUP_ROUTERS.get(group) + if router is not None: + app.include_router(router) diff --git a/ksadk/server/factory.py b/ksadk/server/factory.py new file mode 100644 index 00000000..9478f288 --- /dev/null +++ b/ksadk/server/factory.py @@ -0,0 +1,624 @@ +"""create_runtime_app — 普通 runtime 与 HarnessApp 共用的 app factory (goal-01)。 + +H2 §4.2:这是三线公共装配入口,替代 `ksadk/server/app.py` 的模块级单例 + +`set_runner` 全局态。纯结构性重构,不动业务逻辑。 + +设计要点: + +- **per-app state**:runner / runner_loaded / detached-stream registry 全部挂在 + `app.state.runtime`(:class:`RuntimeAppState`)上,每个 app 实例一份,普通 app 与 + HarnessApp 互不共享。不再有模块级 ``runner`` / ``_DETACHED_*`` 全局可变态。 +- **请求级桥接**:中间件把当前 app 的 state 写入 :data:`current_state` 的 + contextvar,handler 内既有辅助函数经 :func:`get_state` 取 state,从而**不必逐个 + 改写约 30 个 handler 的签名**。后台 detached task 脱离请求上下文,在创建时 + 从 state 捕获 registry(见 ``_detached_streaming_response``)。 +- **route group 化**:路由按域拆成 APIRouter,factory 按 ``config.route_groups`` + 可插拔装配;数据面 group 进 HarnessApp,控制面(cancel/resume/builder/debug) + 不进。 +- **薄兼容壳**:`ksadk/server/app.py` 仍以 ``app`` / ``set_runner`` 暴露,内部 + 改为调用本 factory,老调用方(TestClient / run_server / builders / deploy + manager)零改动。 +""" + +from __future__ import annotations + +import asyncio +import contextvars +import logging +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, cast + +from fastapi import FastAPI + +from ksadk.runners.base_runner import BaseRunner +from ksadk.sandbox.registry import ( + SandboxRegistry, + bind_sandbox_registry, + set_fallback_sandbox_registry, +) + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from ksadk.server.terminal_sessions import TerminalSessionManager + + +# --------------------------------------------------------------------------- +# route group 注册表(冻结稿 G0.1):数据面 vs 控制面 +# --------------------------------------------------------------------------- + +#: 数据面 route group —— 可插拔装配进 HarnessApp。 +DATA_PLANE_GROUPS: frozenset[str] = frozenset( + { + "health_meta", + "sessions", + "sessions_adk_compat", + "run", + "openai_compat", + "workspace", + "models", + "feedback", + "tools", + "ui_bootstrap", + "agui", + } +) + +#: 控制面 route group —— 不进入 HarnessApp(H2 §4.2:控制面 action 不进入)。 +CONTROL_PLANE_GROUPS: frozenset[str] = frozenset( + { + "control", # cancel / resume / checkpoint-resume-preview + "builder", + "debug", + } +) + +#: 全部 group(普通 runtime app 默认装配)。 +ALL_GROUPS: frozenset[str] = DATA_PLANE_GROUPS | CONTROL_PLANE_GROUPS + + +class StreamRegistry: + """per-app detached SSE stream + resume-key 状态。 + + 替代 `app.py` 曾经的模块级 ``_DETACHED_STREAMS`` / ``_DETACHED_STREAMS_BY_INVOCATION`` + / ``_DETACHED_RESUME_KEYS_BY_INVOCATION`` / ``_ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY``。 + 生命周期绑定所属 app(lifespan 清理)。SSE/cancel 行为与旧模块级实现逐点一致。 + """ + + def __init__(self) -> None: + self.streams: set[asyncio.Task[Any]] = set() + self.streams_by_invocation: dict[str, Any] = {} + self.resume_keys_by_invocation: dict[str, tuple[str, str]] = {} + self.active_resume_invocation_by_key: dict[tuple[str, str], str] = {} + + def clear(self) -> None: + self.streams.clear() + self.streams_by_invocation.clear() + self.resume_keys_by_invocation.clear() + self.active_resume_invocation_by_key.clear() + + +class RuntimeAppState: + """per-app 运行时状态及其拥有的可清理资源。""" + + def __init__( + self, + runner: Optional[BaseRunner] = 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.stream_registry = StreamRegistry() + self.sandbox_registry = SandboxRegistry() + self.session_service: Any = None + self._session_services_by_loop: dict[asyncio.AbstractEventLoop, Any] = {} + self._sync_session_service: Any = None + # The legacy ``server.app`` facade exposes monkeypatchable providers. + # New factory callers leave these unset and get per-app owned services. + self._session_service_provider = session_service_provider + self._session_backend_provider = session_backend_provider + # app.py 在装配 terminal routes 时创建,避免 factory 反向依赖 server。 + self.terminal_manager: Optional[TerminalSessionManager] = None + # A2A 装配产物(config.a2a.enabled 时由 _wire_a2a_if_enabled 写入)。 + self.a2a_server: Any = None + self.a2a_bootstrap: Any = None + # AG-UI endpoint 及其 app-owned RuntimeAdapter handle registry。 + self.agui_agent: Any = None + self.agui_config: Any = None + + def resolve_session_service(self) -> Any: + """Return this app's session service for the current execution loop.""" + if self.session_service is not None: + return self.session_service + if self._session_service_provider is not None: + return self._session_service_provider() + + from ksadk.sessions import create_session_service + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + if self._sync_session_service is None: + self._sync_session_service = create_session_service() + return self._sync_session_service + + service = self._session_services_by_loop.get(loop) + if service is None: + service = create_session_service() + self._session_services_by_loop[loop] = service + return service + + def session_services(self) -> list[Any]: + """Return all unique services owned by this app for shutdown.""" + candidates = [ + self.session_service, + self._sync_session_service, + *self._session_services_by_loop.values(), + ] + services: list[Any] = [] + for service in candidates: + if service is not None and all(service is not item for item in services): + services.append(service) + return services + + def describe_session_backend(self) -> dict[str, Any]: + if self._session_backend_provider is not None: + return dict(self._session_backend_provider()) + from ksadk.sessions import describe_session_backend + + return dict(describe_session_backend()) + + +class RuntimeAppConfig: + """create_runtime_app 的装配配置。 + + - ``runner``:依赖注入的 runner(替代 set_runner 全局态);可在装配后由 + 兼容壳 ``set_runner`` 再写入 ``app.state.runtime.runner``。 + - ``runtime_type``:runtime 类型标识(普通 / harness / codex ...)。 + - ``route_groups``:要装配的 route group 集合;默认 :data:`ALL_GROUPS`, + HarnessApp 传 :data:`DATA_PLANE_GROUPS`。 + """ + + 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, + agui: Optional[Any] = 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) + ) + # A2A 协议装配配置(``ksadk.a2a.routes.A2AConfig``);enabled 时 factory 装配 + # A2A 数据面端点(契约 §8)。用 Any 避免本模块硬依赖可选的 a2a-sdk。 + self.a2a = a2a + self.runtime_adapter = runtime_adapter + self.agui = agui + self.session_service_provider = session_service_provider + self.session_backend_provider = session_backend_provider + + +# --------------------------------------------------------------------------- +# 请求级 state 桥接 +# --------------------------------------------------------------------------- + +_current_state: contextvars.ContextVar[Optional[RuntimeAppState]] = contextvars.ContextVar( + "ksadk_runtime_app_state", default=None +) + +# 非请求上下文(直接调用内部 helper 的测试 / run_server 启动前)的兜底 state。 +# 由 app.py 在 factory 建 app 后指向 ``app.state.runtime``。 +_fallback_state: RuntimeAppState = RuntimeAppState() + + +def set_fallback_state(state: RuntimeAppState) -> None: + """把兜底 state 指向某个 app 的 state(兼容壳用)。""" + global _fallback_state + _fallback_state = state + set_fallback_sandbox_registry(state.sandbox_registry) + + +def get_state() -> RuntimeAppState: + """取当前请求对应的 app state;非请求上下文退回兜底 state。""" + state = _current_state.get() + return state if state is not None else _fallback_state + + +@contextmanager +def bind_runtime_state(state: RuntimeAppState) -> Iterator[None]: + """Bind one app state for non-HTTP scopes such as WebSockets.""" + token = _current_state.set(state) + try: + with bind_sandbox_registry(state.sandbox_registry): + yield + finally: + _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 +# --------------------------------------------------------------------------- + +# configure 回调签名:由 app.py 提供,负责把各 domain router 挂到 app 上。 +ConfigureApp = Callable[[FastAPI, RuntimeAppState, set[str]], None] + + +async def shutdown_runtime_resources(state: RuntimeAppState) -> None: + """Close only resources owned by one runtime app.""" + manager = state.terminal_manager + if manager is not None: + try: + await manager.close() + except Exception: + logger.exception("failed to close terminal sessions on shutdown") + + registry = state.stream_registry + pending_streams = list(registry.streams) + for task in pending_streams: + task.cancel() + if pending_streams: + 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") + + for service in state.session_services(): + close = getattr(service, "aclose", None) + if callable(close): + try: + await close() + except Exception: + logger.exception("failed to close app session service on shutdown") + + try: + state.sandbox_registry.close() + except Exception: + logger.exception("failed to clear sandbox registry on shutdown") + + +def create_runtime_app( + config: RuntimeAppConfig, + configure: Optional[ConfigureApp] = None, +) -> FastAPI: + """装配一个 runtime app。 + + 参数: + config: :class:`RuntimeAppConfig`(runner / runtime_type / route_groups)。 + configure: 路由装配回调 ``configure(app, state, route_groups)``;由 + ``ksadk/server/app.py`` 提供,负责按 group 把 domain router include 进 app。 + factory 自身只负责 FastAPI 实例、state、中间件、lifespan、异常处理器, + 不 import 全局 ``base_app`` 再过滤复制路由(H2 禁止)。 + """ + from contextlib import asynccontextmanager + from typing import AsyncIterator + + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import Response + + state = RuntimeAppState( + runner=config.runner, + session_service_provider=config.session_service_provider, + session_backend_provider=config.session_backend_provider, + ) + + @asynccontextmanager + async def _lifespan(app: FastAPI) -> AsyncIterator[None]: + try: + if state.a2a_bootstrap is not None: + await state.a2a_bootstrap.start() + yield + finally: + if state.a2a_bootstrap is not None: + await state.a2a_bootstrap.stop() + await shutdown_runtime_resources(state) + + app = FastAPI( + title="ADK Core API", + description="Agent Development Kit HTTP API", + version="1.0.0", + lifespan=_lifespan, + ) + state.app = app + app.state.runtime = state + + # 中间件:把当前 app 的 state 写入请求级 contextvar(handler 经 get_state() 取)。 + @app.middleware("http") + async def _runtime_state_bridge(request, call_next): + from ksadk.server.routes.dependencies import bind_session_service + + with ( + bind_runtime_state(state), + bind_session_service( + state.resolve_session_service(), + backend=state.describe_session_backend(), + ), + ): + # 保持旧行为:前端入口禁缓存。 + 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" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + # CORS(与旧 app 一致,默认对 ADK 工具放开)。 + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 异常处理器(与旧 app 一致)。 + import json as _json + + from ksadk.sessions.errors import SessionBackendUnavailable + + @app.exception_handler(SessionBackendUnavailable) + async def session_backend_unavailable_handler(_request, exc: SessionBackendUnavailable): + return Response( + content=_json.dumps( + {"detail": {"code": "session_backend_unavailable", "message": str(exc)}}, + ensure_ascii=False, + ), + status_code=503, + media_type="application/json", + ) + + # A2A 端点须在 configure 之前装配:health_meta 含 GET catch-all ``/{requested_path:path}``, + # 若 A2A 后挂,A2A 的 GET 路由(agent-card / tasks/{id})会被 catch-all 遮蔽成 404。 + _wire_a2a_if_enabled(app, state, config) + _wire_agui_if_enabled(app, state, config) + + if configure is not None: + configure(app, state, set(config.route_groups)) + + 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(数据面)。 + + runner 经 :class:`_LazyRunnerProxy` 惰性解析;RuntimeAdapter 用通用 + ``RunnerRuntimeAdapter``(经 P0-2 后 cancel 走真实 asyncio 任务中断)。 + """ + a2a_cfg = config.a2a + if a2a_cfg is None: + return + from ksadk.a2a.bootstrap import AgentEngineA2ABootstrap + + if isinstance(a2a_cfg, AgentEngineA2ABootstrap): + from ksadk.runtime.runner_adapter import RunnerRuntimeAdapter + + proxy = _LazyRunnerProxy(state) + runtime_adapter = config.runtime_adapter or RunnerRuntimeAdapter( + cast("BaseRunner", proxy), + runtime_type=config.runtime_type, + ) + server = a2a_cfg.mount( + app, + runner=proxy, + runtime_adapter=runtime_adapter, + runtime_type=config.runtime_type, + ) + state.a2a_bootstrap = a2a_cfg + state.a2a_server = server + logger.info( + "managed A2A Runtime mounted(agent=%s inbound_enabled=%s)", + a2a_cfg.runtime_metadata.agent_id, + a2a_cfg.inbound_enabled, + ) + return + if not getattr(a2a_cfg, "enabled", False): + 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) + state.a2a_server = server + logger.info("A2A 协议端点已装配进 runtime app(agent=%s)", a2a_cfg.agent_name) + + +def _wire_agui_if_enabled(app: FastAPI, state: RuntimeAppState, config: RuntimeAppConfig) -> None: + """Mount the optional official AG-UI endpoint before the static catch-all.""" + agui_cfg = config.agui + if ( + agui_cfg is None + or not getattr(agui_cfg, "enabled", False) + or "agui" not in config.route_groups + ): + return + + from ksadk.agui.config import require_agui_dependencies + + require_agui_dependencies() + + from ksadk.agui.routes import add_ksadk_agui_endpoint + from ksadk.events.store import RuntimeEventStore + from ksadk.server.routes import dependencies as route_dependencies + + proxy = _LazyRunnerProxy(state) + + class _AutoSessionRuntimeEventStore: + """Create a session on first AG-UI event when HttpAgent starts fresh.""" + + def __init__(self, service: Any) -> None: + self._service = service + self._store = RuntimeEventStore(service) + + async def append_one(self, event: Any) -> Any: + existing = await self._service.get_session(event.session_id) + if existing is None: + await self._service.create_session( + event.agent_id, + event.user_id, + event.session_id, + ) + return await self._store.append_one(event) + + async def reserve_once(self, event: Any) -> Any: + existing = await self._service.get_session(event.session_id) + if existing is None: + await self._service.create_session( + event.agent_id, + event.user_id, + event.session_id, + ) + return await self._store.reserve_once(event) + + async def list(self, session_id: str, **kwargs: Any) -> Any: + return await self._store.list(session_id, **kwargs) + + agent = add_ksadk_agui_endpoint( + app, + cast("BaseRunner", proxy), + agui_cfg, + event_store_factory=lambda: _AutoSessionRuntimeEventStore( + route_dependencies.resolve_session_service() + ), + session_service_factory=route_dependencies.resolve_session_service, + ) + state.agui_agent = agent + state.agui_config = agui_cfg + 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", + "DATA_PLANE_GROUPS", + "RuntimeAppConfig", + "RuntimeAppState", + "bind_runtime_state", + "StreamRegistry", + "create_runtime_app", + "get_runner", + "get_state", + "set_fallback_state", + "wire_default_agui_for_runner", +] diff --git a/ksadk/server/routes/__init__.py b/ksadk/server/routes/__init__.py new file mode 100644 index 00000000..671b53c7 --- /dev/null +++ b/ksadk/server/routes/__init__.py @@ -0,0 +1 @@ +"""Domain routers used by the runtime app composition root.""" diff --git a/ksadk/server/routes/checkpoint_resolution.py b/ksadk/server/routes/checkpoint_resolution.py new file mode 100644 index 00000000..595e93dd --- /dev/null +++ b/ksadk/server/routes/checkpoint_resolution.py @@ -0,0 +1,116 @@ +"""Checkpoint lookup and resume-input resolution.""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from typing import Any + +from fastapi import HTTPException + +from ksadk.sessions import SessionEvent + +from .projection import ( + _apply_checkpoint_resume_audit, + _apply_latest_checkpoint_policy, + _checkpoint_event_to_action_payload, + _iter_session_event_pages, + _record_resume_audit, +) + + +async def _find_session_checkpoint( + *, + service: Any, + session_id: str, + run_id: str, + checkpoint_id: str, +) -> dict[str, Any] | None: + audit_by_session: dict[str, dict[tuple[str, str], dict[str, Any]]] = {} + latest_by_session_run: dict[tuple[str, str], int] = {} + candidate_event: SessionEvent | None = None + async for page in _iter_session_event_pages(service, session_id): + for event in page: + _record_resume_audit(audit_by_session, event) + checkpoint = _checkpoint_event_to_action_payload(event) + if checkpoint is None: + continue + metadata = checkpoint.get("Metadata") or {} + checkpoint_run_id = str(checkpoint.get("RunId") or "") + if metadata.get("only_latest_resumable"): + key = (session_id, checkpoint_run_id) + latest_by_session_run[key] = max( + latest_by_session_run.get(key, 0), + int(checkpoint.get("SeqId") or 0), + ) + if checkpoint_run_id == run_id and checkpoint.get("CheckpointId") == checkpoint_id: + candidate_event = event + if candidate_event is None: + return None + checkpoint = _checkpoint_event_to_action_payload(candidate_event) + if checkpoint is None: + return None + checkpoint = _apply_checkpoint_resume_audit( + checkpoint, + audit_by_session.get(session_id, {}), + ) + return _apply_latest_checkpoint_policy( + checkpoint, + latest_by_session_run, + session_id=session_id, + ) + + +async def _resolve_checkpoint_resume_input_from_session( + *, + service: Any, + agent_id: str, + session_id: str | None, + resume_input: Mapping[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(resume_input, Mapping): + return None + if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint": + return dict(resume_input) + normalized_session_id = str(session_id or "").strip() + if not normalized_session_id: + raise HTTPException(status_code=400, detail="Checkpoint resume requires session_id") + + session = await service.get_session(normalized_session_id) + if not session or session.agent_id != agent_id: + raise HTTPException(status_code=404, detail="Session not found") + + 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 HTTPException( + status_code=400, detail="Checkpoint resume requires run_id and checkpoint_id" + ) + + checkpoint = await _find_session_checkpoint( + service=service, + session_id=normalized_session_id, + run_id=run_id, + checkpoint_id=checkpoint_id, + ) + if checkpoint is None: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip() + return { + "type": "agentengine.resume_checkpoint", + "run_id": run_id, + "checkpoint_id": checkpoint_id, + "resume_attempt_id": resume_attempt_id or f"resume_{uuid.uuid4().hex}", + "framework": checkpoint["Framework"], + "framework_ref": checkpoint["FrameworkRef"], + "metadata": dict(checkpoint.get("Metadata") or {}), + "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}), + "resume_instruction_enabled": bool( + resume_input.get("resume_instruction_enabled") + or resume_input.get("ResumeInstructionEnabled") + ), + "resume_instruction": str( + resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or "" + ).strip(), + } diff --git a/ksadk/server/routes/common.py b/ksadk/server/routes/common.py new file mode 100644 index 00000000..7ace0066 --- /dev/null +++ b/ksadk/server/routes/common.py @@ -0,0 +1,713 @@ +"""Shared runtime server helpers, integrated resources, and health routes.""" + +from __future__ import annotations + +import base64 +import io +import logging +import os +import uuid +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional + +import httpx +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, Response + +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 ( + TerminalSessionManager, + native_terminal_supported, + register_terminal_routes, +) +from ksadk.sessions import Session +from ksadk.sessions.local_service import resolve_local_session_dir +from ksadk.ui_config import UI_PROFILE_CUSTOM, resolve_ui_config +from ksadk_runtime_common.workspace_files import ( + create_workspace_files_router, + workspace_files_enabled, +) + +from . import dependencies as deps +from .routers import health_meta_router + +logger = logging.getLogger(__name__) +STATIC_DIR = Path(__file__).parent.parent / "static" + +_RESERVED_UI_PATHS = {"/", "/chat", "/build", "/deploy"} +_CUSTOM_API_PROXY_ENV_KEYS = ("KSADK_USER_BACKEND_URL", "LUOLUO_USER_BACKEND_URL") +_HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + +_TEXT_MIME_PREFIXES = ("text/",) +_TEXT_MIME_TYPES = { + "application/json", + "application/pdf", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/x-ndjson", +} +_TEXT_FILE_EXTENSIONS = { + ".txt", + ".md", + ".markdown", + ".json", + ".yaml", + ".yml", + ".csv", + ".tsv", + ".log", + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".html", + ".css", + ".sql", + ".xml", + ".sh", +} +_MAX_INLINE_BASE64_CHARS = 4_000_000 +_MAX_INLINE_TEXT_CHARS = 20_000 +_MAX_REFERENCE_TEXT_BYTES = 3_000_000 +_UPLOAD_URI_SCHEME = "ksadk-upload://" + + +def _workspace_root_dir() -> Path: + return Path(resolve_local_session_dir()) / "workspace" + + +_NATIVE_TUI_FRAMEWORKS = {"hermes", "openclaw"} + + +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() + + +def _build_native_terminal_capability(framework: str) -> dict[str, Any]: + enabled = ( + native_terminal_supported() + and str(framework or "").strip().lower() in _NATIVE_TUI_FRAMEWORKS + ) + return { + "Enabled": enabled, + "Mode": "tui" if enabled else None, + "Protocol": "ks-terminal.v1", + "Path": "/_ksadk/terminal/ws" if enabled else None, + } + + +def _register_integrated_routers(app: FastAPI, state: RuntimeAppState) -> None: + """装配随 app 走的内嵌 router(workspace files + terminal ws)。 + + goal-01:由模块级 ``app.include_router(...)`` 改为在 factory 的 configure + 回调里调用,普通 app 与 HarnessApp 各自装配,互不共享。 + """ + app.include_router( + create_workspace_files_router( + root_getter=_workspace_root_dir, + enabled_getter=lambda: workspace_files_enabled(default=True), + ) + ) + state.terminal_manager = TerminalSessionManager( + workspace_root_getter=_workspace_root_dir, + framework_getter=_current_framework, + ) + register_terminal_routes( + app, + state.terminal_manager, + bind_context=lambda: bind_runtime_state(state), + ) + + +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")), + ("MODEL_NAME", os.getenv("MODEL_NAME")), + ("COZE_MODEL_NAME", os.getenv("COZE_MODEL_NAME")), + ) + for source, value in candidates: + model = str(value or "").strip() + if model: + return model, source + return None, None + + +def _build_bootstrap_model_payload() -> Optional[dict[str, Any]]: + current_model, source = _resolve_current_model() + if not current_model: + return None + + payload = dict(normalize_model_metadata({"id": current_model})) + payload["source"] = source + return payload + + +def _runner_project_dir() -> Path: + runner = get_state().runner + if runner and getattr(runner, "project_dir", None): + try: + return Path(str(runner.project_dir)).resolve() + except Exception: + pass + return Path(".").resolve() + + +def _default_custom_ui_bundle_dir(project_dir: Path) -> Path: + return project_dir / "research-ui" / "dist" + + +def _ui_state_with_env_fallback(state: dict[str, Any]) -> dict[str, Any]: + merged = dict(state or {}) + env_fallbacks = { + "ui_profile": os.environ.get("KSADK_UI_PROFILE"), + "ui_path": os.environ.get("KSADK_UI_PATH"), + "ui_url": os.environ.get("KSADK_UI_URL"), + "ui_bundle_path": os.environ.get("KSADK_UI_BUNDLE_PATH"), + } + for key, value in env_fallbacks.items(): + if value and not merged.get(key): + merged[key] = value + return merged + + +def _resolve_agent_ui_spec() -> dict[str, Any]: + project_dir = _runner_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) + if ( + not state.get("ui_profile") + and not state.get("ui_path") + and not state.get("ui_url") + and (auto_custom_bundle_dir / "index.html").exists() + ): + state["ui_profile"] = UI_PROFILE_CUSTOM + state["ui_path"] = "/" + state["ui_bundle_path"] = str(auto_custom_bundle_dir) + config = resolve_ui_config( + framework=framework, + state=state, + cli_profile=None, + cli_path=None, + cli_url=None, + ) + + if config.profile == UI_PROFILE_CUSTOM: + bundle_dir_value = state.get("ui_bundle_path") or state.get("ui_bundle_dir") + bundle_dir = None + if bundle_dir_value: + candidate = Path(str(bundle_dir_value)) + if not candidate.is_absolute(): + candidate = project_dir / candidate + if candidate.exists(): + bundle_dir = candidate.resolve() + if bundle_dir is None: + bundle_dir = _default_custom_ui_bundle_dir(project_dir) + index_file = bundle_dir / "index.html" + enabled = bundle_dir.exists() and index_file.exists() + return { + "enabled": enabled, + "profile": config.profile, + "ui_profile": config.profile, + "path": config.path, + "ui_path": config.path, + "url": config.url, + "ui_url": config.url, + "bundle_path": str(bundle_dir), + "ui_bundle_path": str(bundle_dir), + "index_path": str(index_file), + "source": "custom", + } + + bundle_dir = STATIC_DIR + index_file = bundle_dir / "index.html" + enabled = bundle_dir.exists() and index_file.exists() + return { + "enabled": enabled, + "profile": config.profile, + "ui_profile": config.profile, + "path": config.path, + "ui_path": config.path, + "url": config.url, + "ui_url": config.url, + "bundle_path": str(bundle_dir), + "ui_bundle_path": str(bundle_dir), + "index_path": str(index_file), + "source": "builtin", + } + + +def _normalize_request_ui_path(request_path: str) -> str: + path = "/" + str(request_path or "").lstrip("/") + return path if path != "//" else "/" + + +def _is_custom_ui_static_asset_path(relative_path: str) -> bool: + path = str(relative_path or "").strip("/") + if not path: + return False + first_segment = path.split("/", 1)[0] + return first_segment == "assets" or bool(Path(path).suffix) + + +def _find_ui_static_asset(bundle_dir: Path, requested_relative_path: str) -> Path | None: + """Look up an asset without constructing a filesystem path from HTTP input.""" + requested = str(requested_relative_path or "").strip("/") + if not requested: + return None + root = bundle_dir.resolve() + if not root.is_dir(): + return None + for asset in root.rglob("*"): + if not asset.is_file(): + continue + resolved = asset.resolve() + try: + relative = resolved.relative_to(root).as_posix() + except ValueError: + continue + if relative == requested: + return resolved + return None + + +def _resolve_ui_static_response(request_path: str) -> Optional[FileResponse]: + spec = _resolve_agent_ui_spec() + if not spec.get("enabled"): + return None + + bundle_dir = Path(str(spec["bundle_path"])) + index_file = Path(str(spec["index_path"])) + path = _normalize_request_ui_path(request_path) + + if spec.get("source") == "custom": + ui_path = _normalize_request_ui_path(str(spec.get("path") or "/")).rstrip("/") or "/" + if path == ui_path or path == f"{ui_path}/": + return FileResponse(index_file) + if ui_path != "/" and not path.startswith(f"{ui_path}/"): + return None + relative = path[len(ui_path) :].lstrip("/") if ui_path != "/" else path.lstrip("/") + if not relative: + return FileResponse(index_file) + asset = _find_ui_static_asset(bundle_dir, relative) + if asset is not None: + return FileResponse(asset) + if not _is_custom_ui_static_asset_path(relative): + return FileResponse(index_file) + return None + + if path in _RESERVED_UI_PATHS: + return FileResponse(index_file) + + asset = _find_ui_static_asset(bundle_dir, path.lstrip("/")) + if asset is not None: + return FileResponse(asset) + return None + + +def _is_textual_mime(mime_type: str) -> bool: + mime = (mime_type or "").lower() + if not mime: + return False + return mime.startswith(_TEXT_MIME_PREFIXES) or mime in _TEXT_MIME_TYPES + + +def _looks_like_textual_attachment(mime_type: str, display_name: str) -> bool: + suffix = Path(display_name or "").suffix.lower() + return _is_textual_mime(mime_type) or suffix in _TEXT_FILE_EXTENSIONS + + +def _extract_pdf_text(raw: bytes) -> str: + try: + from pypdf import PdfReader + except Exception: + return "" + + try: + reader = PdfReader(io.BytesIO(raw)) + except Exception: + return "" + + segments: List[str] = [] + for page in reader.pages[:10]: + try: + page_text = page.extract_text() or "" + except Exception: + page_text = "" + if page_text: + segments.append(page_text) + + return "\n".join(segments).strip() + + +def _decode_inline_data(data_b64: str) -> bytes: + return bytes(base64.b64decode((data_b64 or "").strip() + "===")) + + +def _resolve_uploads_dir() -> Path: + uploads_dir = Path(resolve_local_session_dir()) / "files" + uploads_dir.mkdir(parents=True, exist_ok=True) + return uploads_dir + + +def _resolve_attachment_storage_path(file_uri: str) -> Optional[Path]: + normalized_uri = (file_uri or "").strip() + if not normalized_uri: + return None + + if normalized_uri.startswith("local:"): + path = Path(normalized_uri[6:]).expanduser() + return path.resolve() + + if normalized_uri.startswith(_UPLOAD_URI_SCHEME): + file_id = normalized_uri.removeprefix(_UPLOAD_URI_SCHEME).strip("/") + if not file_id: + return None + + for candidate in sorted(_resolve_uploads_dir().glob(f"{file_id}*")): + if candidate.is_file(): + return candidate.resolve() + + return None + + +def _custom_api_proxy_base_url() -> str: + for key in _CUSTOM_API_PROXY_ENV_KEYS: + value = os.environ.get(key) + if value and value.strip(): + return value.strip().rstrip("/") + return "" + + +def _proxy_headers(headers: Mapping[str, str]) -> dict[str, str]: + return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS} + + +def _response_headers(headers: Mapping[str, str]) -> dict[str, str]: + return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS} + + +@health_meta_router.api_route( + "/api/{proxy_path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + include_in_schema=False, +) +async def custom_api_proxy(proxy_path: str, request: Request): + base_url = _custom_api_proxy_base_url() + if not base_url: + raise HTTPException(status_code=404, detail="Custom API backend is not configured") + + path = f"/api/{proxy_path.lstrip('/')}" + query = request.url.query + target_url = f"{base_url}{path}" + if query: + target_url = f"{target_url}?{query}" + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + upstream = await client.request( + request.method, + target_url, + content=await request.body(), + headers=_proxy_headers(request.headers), + ) + except httpx.HTTPError as exc: + raise HTTPException( + status_code=502, detail=f"Custom API backend unavailable: {exc}" + ) from exc + + return Response( + content=upstream.content, + status_code=upstream.status_code, + headers=_response_headers(upstream.headers), + media_type=upstream.headers.get("content-type"), + ) + + +def _read_attachment_bytes( + storage_path: Optional[Path], *, size_limit: Optional[int] = None +) -> Optional[bytes]: + if storage_path is None or not storage_path.is_file(): + return None + + try: + if size_limit is not None and storage_path.stat().st_size > size_limit: + return None + return storage_path.read_bytes() + except OSError: + return None + + +def _extract_inline_attachment_text(*, display_name: str, mime_type: str, raw: bytes) -> str: + if mime_type == "application/pdf" or display_name.lower().endswith(".pdf"): + text = _extract_pdf_text(raw) + if not text: + return "" + if len(text) > _MAX_INLINE_TEXT_CHARS: + return text[:_MAX_INLINE_TEXT_CHARS] + "\n...[内容已截断]" + return text + + if _looks_like_textual_attachment(mime_type, display_name): + text = raw.decode("utf-8", errors="ignore") + if len(text) > _MAX_INLINE_TEXT_CHARS: + return text[:_MAX_INLINE_TEXT_CHARS] + "\n...[内容已截断]" + return text + + return "" + + +def _attachment_prompt_text(attachment: Dict[str, Any]) -> str: + display_name = str(attachment.get("display_name") or "uploaded_file") + mime_type = str(attachment.get("mime_type") or "application/octet-stream") + transport = str(attachment.get("transport") or "") + + if transport == "inline": + data_b64 = str(attachment.get("data") or "").strip() + if len(data_b64) > _MAX_INLINE_BASE64_CHARS: + return ( + f"[上传文件: {display_name}, mime={mime_type or 'unknown'}, 内容过大,未直接展开]" + ) + + try: + raw = _decode_inline_data(data_b64) + except Exception: + return f"[上传文件: {display_name}, 内容解码失败]" + + text = _extract_inline_attachment_text( + display_name=display_name, + mime_type=mime_type, + raw=raw, + ) + if text: + return f"[上传文件: {display_name}]\n{text}" + return ( + "[上传文件: " + f"{display_name}, " + f"mime={mime_type or 'application/octet-stream'}, " + f"bytes={len(raw)}]" + ) + + storage_path_value = attachment.get("storage_path") + storage_path = Path(str(storage_path_value)) if storage_path_value else None + size_bytes = attachment.get("size_bytes") + if size_bytes is None and storage_path is not None and storage_path.exists(): + try: + size_bytes = storage_path.stat().st_size + except OSError: + size_bytes = None + + reference_bytes = _read_attachment_bytes(storage_path, size_limit=_MAX_REFERENCE_TEXT_BYTES) + if reference_bytes is not None: + text = _extract_inline_attachment_text( + display_name=display_name, + mime_type=mime_type, + raw=reference_bytes, + ) + if text: + return f"[上传文件: {display_name}]\n{text}" + return ( + "[上传文件: " + f"{display_name}, " + f"mime={mime_type or 'application/octet-stream'}, " + f"bytes={len(reference_bytes)}]" + ) + + if size_bytes and size_bytes > _MAX_REFERENCE_TEXT_BYTES: + return ( + "[上传文件: " + f"{display_name}, " + f"mime={mime_type or 'unknown'}, " + f"bytes={size_bytes}, " + "内容过大,未直接展开]" + ) + + file_uri = attachment.get("file_uri") or "" + return f"[上传文件引用: {display_name or file_uri}, mime={mime_type or 'unknown'}]" + + +def _extract_user_input_from_parts(parts: List[Any]) -> str: + """兼容旧测试/旧调用点,统一复用 conversations 层的规范化逻辑。""" + + return str(deps.conversation().extract_user_input_from_parts(parts)) + + +def _attachment_from_part(part: Any) -> Optional[Dict[str, Any]]: + """兼容旧入口,真实实现已经收口到 conversations.normalize。""" + + attachment = deps.conversation().attachment_from_part(part) + return dict(attachment) if isinstance(attachment, Mapping) else None + + +async def _hydrate_session(session: Optional[Session]) -> Optional[Session]: + if not session: + return None + session.events = await deps.resolve_session_service().get_events(session.id) + return session + + +async def _ensure_session(agent_id: str, user_id: str, session_id: Optional[str]) -> Session: + service = deps.resolve_session_service() + if session_id: + existing = await service.get_session(session_id) + if existing: + if existing.agent_id != agent_id or existing.user_id != user_id: + raise HTTPException( + status_code=409, + detail="Session id belongs to a different agent or user", + ) + return await _hydrate_session(existing) or existing + created = await service.create_session(agent_id, user_id, session_id=session_id) + return await _hydrate_session(created) or created + + created = await service.create_session(agent_id, user_id) + return await _hydrate_session(created) or created + + +def _sanitize_session_state_for_action(state: Mapping[str, Any] | None) -> dict[str, Any]: + sanitized = dict(state or {}) + attachment_context = sanitized.get(deps.conversation().runtime.ATTACHMENT_CONTEXT_STATE_KEY) + if not isinstance(attachment_context, Mapping): + return sanitized + + attachments = [ + deps.conversation().compact_attachment_for_session(item) + for item in attachment_context.get("attachments") or [] + if isinstance(item, dict) + ] + attachment_results = [ + compact_attachment_result_for_session(item) + for item in attachment_context.get("attachment_results") or [] + if isinstance(item, dict) + ] + sanitized[deps.conversation().runtime.ATTACHMENT_CONTEXT_STATE_KEY] = { + "attachments": attachments, + "attachment_results": attachment_results, + } + return sanitized + + +def _request_id() -> str: + return f"req-{uuid.uuid4().hex[:12]}" + + +def _action_response( + action: str, data: Any, *, request_id: Optional[str] = None, message: str = "Success" +) -> dict: + payload = { + "Code": 0, + "Message": message, + "RequestId": request_id or _request_id(), + "Data": data, + } + if action: + payload["Action"] = action + return payload + + +async def _workspace_runtime_request( + method: str, + runtime_path: str, + *, + params: Optional[Dict[str, Any]] = None, + files: Optional[Dict[str, Any]] = None, +) -> httpx.Response: + runtime_app = get_state().app + if runtime_app is None: + raise RuntimeError("runtime app is not bound to the current request") + transport = httpx.ASGITransport(app=runtime_app) + async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client: + response = await client.request( + method, + runtime_path, + params=params, + files=files, + ) + + if response.status_code >= 400: + detail = response.text + try: + payload = response.json() + except Exception: + payload = None + if isinstance(payload, dict): + detail = str(payload.get("detail") or detail) + raise HTTPException( + status_code=response.status_code, detail=detail or "Workspace request failed" + ) + return response + + +# ============================================================ +# Core ADK API Endpoints +# ============================================================ + + +@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 + 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" + return [name] diff --git a/ksadk/server/routes/control.py b/ksadk/server/routes/control.py new file mode 100644 index 00000000..94ca8cce --- /dev/null +++ b/ksadk/server/routes/control.py @@ -0,0 +1,327 @@ +"""Cancel, resume, preview, and run-event subscription routes.""" + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from collections.abc import AsyncIterator, Mapping +from typing import Any, Optional + +from fastapi import HTTPException, Query +from fastapi.responses import StreamingResponse + +from ksadk.conversations.run_kinds import ( + RUN_MODE_BACKGROUND, + RUN_MODE_FOREGROUND, + RUN_TRIGGER_CHECKPOINT_RESUME, +) + +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 .models import ( + _MAX_PREVIEW_TOOL_RECEIPTS, + _RUN_TERMINAL_STATUSES, + GetCheckpointResumePreviewActionRequest, + ResumeRunActionRequest, + _run_agent_response_metadata, + _split_custom_metadata, +) +from .projection import ( + _SIDE_EFFECT_TOOL_NAMES, + _build_checkpoint_resume_preview, + _checkpoint_resume_disabled_detail, + _event_to_action_payload, + _iter_session_event_pages, + _latest_invocation_status, + _oldest_unconsumed_session_events, + _require_action_session, + _session_contains_invocation, + _tool_receipt_event_to_action_payload, +) +from .routers import control_router, run_router +from .streaming import ( + _detached_resume_key_from_input, + _reject_if_detached_resume_active, +) + + +@control_router.post("/agentengine/api/v1/GetCheckpointResumePreview") +async def get_checkpoint_resume_preview_action(request: GetCheckpointResumePreviewActionRequest): + service = deps.resolve_session_service() + await _require_action_session( + service, + session_id=request.SessionId, + agent_id=request.AgentId, + user_id=request.UserId, + ) + + checkpoint = await _find_session_checkpoint( + service=service, + session_id=request.SessionId, + run_id=str(request.RunId), + checkpoint_id=str(request.CheckpointId), + ) + if checkpoint is None: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + checkpoint_seq_id = int(checkpoint.get("SeqId") or 0) + checkpoint_run_id = str(checkpoint.get("RunId") or "") + receipts: list[dict[str, Any]] = [] + receipt_total = 0 + side_effect_receipt_count = 0 + failed_receipt_count = 0 + async for page in _iter_session_event_pages( + service, + request.SessionId, + before_seq_id=checkpoint_seq_id + 1 if checkpoint_seq_id else None, + ): + for event in page: + receipt = _tool_receipt_event_to_action_payload(event) + if receipt is None: + continue + if checkpoint_run_id and receipt["RunId"] and receipt["RunId"] != checkpoint_run_id: + continue + receipt_total += 1 + if receipt["ToolName"] in _SIDE_EFFECT_TOOL_NAMES: + side_effect_receipt_count += 1 + if receipt["Status"] == "failed": + failed_receipt_count += 1 + if len(receipts) < _MAX_PREVIEW_TOOL_RECEIPTS: + receipts.append(receipt) + + return _action_response( + "GetCheckpointResumePreview", + { + "Preview": _build_checkpoint_resume_preview( + checkpoint=checkpoint, + receipts=receipts, + receipt_total=receipt_total, + side_effect_receipt_count=side_effect_receipt_count, + failed_receipt_count=failed_receipt_count, + ) + }, + ) + + +@control_router.post("/agentengine/api/v1/ResumeRun") +async def resume_run_action(request: ResumeRunActionRequest): + service = deps.resolve_session_service() + session = await _require_action_session( + service, + session_id=request.SessionId, + agent_id=request.AgentId, + user_id=request.UserId, + ) + + checkpoint = await _find_session_checkpoint( + service=service, + session_id=request.SessionId, + run_id=str(request.RunId), + checkpoint_id=str(request.CheckpointId), + ) + if checkpoint is None: + raise HTTPException(status_code=404, detail="Checkpoint not found") + disabled_detail = _checkpoint_resume_disabled_detail(checkpoint) + if disabled_detail is not None: + if disabled_detail.get("is_terminal"): + resume_attempt_id = str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}") + invocation_id = str(request.InvocationId or resume_attempt_id) + await deps.conversation().append_run_resume_event( + session_id=request.SessionId, + author=request.AgentId, + run_id=str(request.RunId), + checkpoint_id=str(request.CheckpointId), + resume_attempt_id=resume_attempt_id, + framework=checkpoint["Framework"], + framework_ref=checkpoint["FrameworkRef"], + invocation_id=invocation_id, + session_service_provider=deps.resolve_session_service, + ) + await deps.conversation().append_run_status_event( + session_id=request.SessionId, + author=request.AgentId, + status="completed", + invocation_id=invocation_id, + detail="resume_noop_terminal_checkpoint", + session_service_provider=deps.resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, + ) + return _action_response( + "ResumeRun", + { + "status": "noop", + "Reason": disabled_detail["reason"], + "CheckpointId": disabled_detail["checkpoint_id"], + "RunId": disabled_detail["run_id"], + "ResumeAttemptId": resume_attempt_id, + }, + ) + raise HTTPException(status_code=409, detail=disabled_detail) + + resume_input = { + "type": "agentengine.resume_checkpoint", + "run_id": str(request.RunId), + "checkpoint_id": str(request.CheckpointId), + "resume_attempt_id": str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}"), + "framework": checkpoint["Framework"], + "framework_ref": checkpoint["FrameworkRef"], + "metadata": dict(checkpoint.get("Metadata") or {}), + "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}), + "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 = { + "responses_conversation": True, + } + + if request.Stream: + resume_invocation_id = str(request.InvocationId or resume_input["resume_attempt_id"]) + resume_key = _detached_resume_key_from_input(request.SessionId, resume_input) + _reject_if_detached_resume_active(resume_key) + # 与 RunAgent Background 同款:返回 SSE 前同步落 resuming 起始事件。 + # detached turn 的首个事件要等流被消费才写;UI 拿到响应头会立刻调 + # SubscribeRunEvents,其 _session_contains_invocation 校验若抢在首次写入前 + # 会误判 409 "InvocationId does not belong to SessionId"。 + # append_run_status_event 按 (invocation_id, status) 幂等,turn 内的补写会去重。 + await deps.conversation().append_run_status_event( + session_id=request.SessionId, + author=request.AgentId, + status="resuming", + invocation_id=resume_invocation_id, + detail="checkpoint_resume", + session_service_provider=deps.resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, + ) + return deps.detached_streaming_response( + deps.conversation().stream_responses_conversation_turn( + runner=active_runner, + agent_id=request.AgentId, + user_id=user_id, + messages=[], + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata=resume_request_metadata, + custom_metadata=custom_metadata, + 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, + ), + invocation_id=resume_invocation_id, + resume_key=resume_key, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME, + ) + + response_id = f"resp_{uuid.uuid4().hex}" + resolved_session_id, result = await deps.conversation().invoke_conversation_once( + runner=active_runner, + agent_id=request.AgentId, + user_id=user_id, + messages=[], + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata=resume_request_metadata, + custom_metadata=custom_metadata, + 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( + output_text=result["output_text"], + model=request.Model, + session_id=resolved_session_id, + response_id=response_id, + metadata=_run_agent_response_metadata(custom_metadata, result), + usage=result.get("usage") if isinstance(result.get("usage"), Mapping) else None, + ) + return _action_response("ResumeRun", payload) + + +@run_router.get("/agentengine/api/v1/SubscribeRunEvents", include_in_schema=False) +async def subscribe_run_events_action( + SessionId: str = Query(...), + InvocationId: str = Query(...), + AfterSeqId: int = Query(0), + AgentId: Optional[str] = Query(None), + UserId: Optional[str] = Query(None), +): + session_id = str(SessionId or "").strip() + invocation_id = str(InvocationId or "").strip() + if not session_id or not invocation_id: + raise HTTPException(status_code=400, detail="SessionId and InvocationId are required") + service = deps.resolve_session_service() + await _require_action_session( + service, + session_id=session_id, + agent_id=AgentId, + user_id=UserId, + ) + if not await _session_contains_invocation(service, session_id, invocation_id): + raise HTTPException( + status_code=409, + detail="InvocationId does not belong to SessionId", + ) + + async def event_generator() -> AsyncIterator[str]: + last_seq_id = int(AfterSeqId or 0) + deadline = time.monotonic() + 5 * 60 + last_heartbeat_at = time.monotonic() + last_terminal_check_at = 0.0 + while True: + events = await _oldest_unconsumed_session_events( + service, + session_id, + after_seq_id=last_seq_id, + ) + for event in events: + last_seq_id = max(last_seq_id, event.seq_id) + if event.invocation_id != invocation_id: + continue + payload = _event_to_action_payload(event) + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + last_heartbeat_at = time.monotonic() + if ( + event.event_type == "run_status" + and str((event.content or {}).get("status") or "").strip().lower() + in _RUN_TERMINAL_STATUSES + ): + yield "data: [DONE]\n\n" + return + + now = time.monotonic() + if not events and now - last_terminal_check_at >= deps.heartbeat_interval(): + latest_status = await _latest_invocation_status( + service, + session_id, + invocation_id, + ) + last_terminal_check_at = now + if latest_status in _RUN_TERMINAL_STATUSES: + yield "data: [DONE]\n\n" + return + if now - last_heartbeat_at >= deps.heartbeat_interval(): + yield ": heartbeat\n\n" + last_heartbeat_at = now + if now > deadline: + return + await asyncio.sleep(0.25) + + return StreamingResponse(event_generator(), media_type="text/event-stream") diff --git a/ksadk/server/routes/dependencies.py b/ksadk/server/routes/dependencies.py new file mode 100644 index 00000000..f4790ee9 --- /dev/null +++ b/ksadk/server/routes/dependencies.py @@ -0,0 +1,98 @@ +"""Explicit compatibility dependencies shared by extracted route modules. + +The default app keeps a few historically monkeypatchable exports. Providers are +callbacks so route modules observe those replacements without importing the app +module back into the domain layer. +""" + +from __future__ import annotations + +import contextvars +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Callable, Iterator + + +@dataclass(frozen=True) +class ServerRouteDependencies: + resolve_session_service: Callable[[], Any] + describe_session_backend: Callable[[], dict[str, Any]] + resolve_agent_ui_spec: Callable[[], dict[str, Any]] + conversation: Callable[[], Any] + detached_streaming_response: Callable[..., Any] + detached_stream_class: Callable[[], type[Any]] + heartbeat_interval: Callable[[], float] + runtime_app: Callable[[], Any] + + +_dependencies: ServerRouteDependencies | None = None +_session_service_override: contextvars.ContextVar[Any | None] = contextvars.ContextVar( + "ksadk_route_session_service", default=None +) +_session_backend_override: contextvars.ContextVar[dict[str, Any] | None] = contextvars.ContextVar( + "ksadk_route_session_backend", default=None +) + + +def configure(dependencies: ServerRouteDependencies) -> None: + global _dependencies + _dependencies = dependencies + + +def current() -> ServerRouteDependencies: + if _dependencies is None: + raise RuntimeError("server route dependencies are not configured") + return _dependencies + + +def resolve_session_service() -> Any: + override = _session_service_override.get() + if override is not None: + return override + return current().resolve_session_service() + + +@contextmanager +def bind_session_service(service: Any, *, backend: dict[str, Any] | None = None) -> Iterator[None]: + """Bind an app-owned session service for one request context.""" + from ksadk.sessions import bind_session_service as bind_runtime_session_service + + service_token = _session_service_override.set(service) + backend_token = _session_backend_override.set(backend) + try: + with bind_runtime_session_service(service): + yield + finally: + _session_backend_override.reset(backend_token) + _session_service_override.reset(service_token) + + +def describe_session_backend() -> dict[str, Any]: + override = _session_backend_override.get() + if override is not None: + return dict(override) + return current().describe_session_backend() + + +def resolve_agent_ui_spec() -> dict[str, Any]: + return current().resolve_agent_ui_spec() + + +def conversation() -> Any: + return current().conversation() + + +def detached_streaming_response(*args: Any, **kwargs: Any) -> Any: + return current().detached_streaming_response(*args, **kwargs) + + +def detached_stream_class() -> type[Any]: + return current().detached_stream_class() + + +def heartbeat_interval() -> float: + return current().heartbeat_interval() + + +def runtime_app() -> Any: + return current().runtime_app() diff --git a/ksadk/server/routes/misc.py b/ksadk/server/routes/misc.py new file mode 100644 index 00000000..c9e28de3 --- /dev/null +++ b/ksadk/server/routes/misc.py @@ -0,0 +1,448 @@ +"""Static UI, builder, trace, feedback, and ADK compatibility routes.""" + +from __future__ import annotations + +import json +import time +import uuid +from collections.abc import Mapping +from typing import Any, Dict, List, Optional, cast + +from fastapi import HTTPException, Request + +from ksadk.sessions import SessionEvent +from ksadk.tracing import get_memory_exporter + +from . import dependencies as deps +from .common import ( + _action_response, + _ensure_runner_loaded, + _ensure_session, + _hydrate_session, + _resolve_ui_static_response, +) +from .models import ResponseFeedbackRefActionRequest, UpsertResponseFeedbackActionRequest +from .routers import ( + builder_router, + debug_router, + feedback_router, + health_meta_router, + models_router, + sessions_adk_compat_router, +) +from .workspace import ListAgentModelsRequest, _build_models_payload + + +@health_meta_router.get("/{requested_path:path}", include_in_schema=False) +async def serve_agent_ui_static(requested_path: str): + response = _resolve_ui_static_response(requested_path) + if response is not None: + return response + raise HTTPException(status_code=404, detail="Not Found") + + +# ============================================================ +# goal-01: create_runtime_app factory 装配(普通 runtime app 与 HarnessApp 共用入口) +# ============================================================ + +# ---- builder 域(ADK-Web stub)---- + + +@builder_router.get("/builder/app/{app_name}") +async def get_agent_builder( + app_name: str, ts: int = 0, tmp: bool = False, file_path: Optional[str] = None +): + """Get agent builder config - stub for ADK-Web""" + # Return minimal YAML config for non-ADK projects + return f"""name: {app_name} +model: glm-5.1 +description: {app_name} agent +instruction: You are a helpful assistant. +""" + + +@builder_router.post("/builder/save") +async def save_agent_builder(request: Request, tmp: bool = False): + """Save agent builder config - stub for ADK-Web""" + return True + + +# ---- models 域 ---- + + +@models_router.post("/agentengine/api/v1/ListAgentModels") +async def list_agent_models_action(_request: ListAgentModelsRequest): + payload = await _build_models_payload() + return _action_response( + "ListAgentModels", + { + "Models": payload.get("data", []), + "Current": payload.get("current"), + "Source": payload.get("source", ""), + }, + ) + + +# ---- debug 域(OpenTelemetry trace 查询)---- + + +@debug_router.get("/debug/trace/session/{session_id}") +async def get_session_trace(session_id: str): + """Get traces for a session - returns array of Span objects""" + exporter = get_memory_exporter() + if not exporter: + return [] # Return empty array, not object + + # Get all spans and transform to ADK-Web expected format + raw_spans = exporter.get_finished_spans() + + # Get session events for invocation mapping + service = deps.resolve_session_service() + events = await service.get_events(session_id) + + # Build invocation ID mapping from session events + invocation_ids = {} + for event in events: + if event.id and event.invocation_id: + invocation_ids[event.id] = event.invocation_id + + # Transform spans to ADK-Web format + spans = [] + for span in raw_spans: + # Use session_id as trace_id for grouping + trace_id = span.get("trace_id", session_id) + + # Get or create invocation_id + invocation_id = span.get("attributes", {}).get("gcp.vertex.agent.invocation_id") + if not invocation_id: + # Try to derive from event association + invocation_id = trace_id[:36] if len(trace_id) >= 36 else trace_id + + # Build attributes with required ADK fields + attrs = span.get("attributes", {}).copy() + attrs["gcp.vertex.agent.invocation_id"] = invocation_id + + # If this is a LLM span, add request/response + if "llm" in span.get("name", "").lower() or "invoke" in span.get("name", "").lower(): + if "user.input" in attrs: + attrs["gcp.vertex.agent.llm_request"] = json.dumps( + { + "contents": [ + {"role": "user", "parts": [{"text": attrs.get("user.input", "")}]} + ] + } + ) + if "agent.output" in attrs: + attrs["gcp.vertex.agent.llm_response"] = json.dumps( + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": attrs.get("agent.output", "")}], + } + } + ] + } + ) + + formatted_span = { + "trace_id": trace_id, + "span_id": span.get("span_id", str(uuid.uuid4())[:16]), + "parent_span_id": span.get("parent_span_id"), + "name": span.get("name", "unknown"), + "start_time": span.get("start_time", 0), + "end_time": span.get("end_time", 0), + "attributes": attrs, + "status": span.get("status", {}), + } + spans.append(formatted_span) + + return spans # Return array directly + + +@debug_router.get("/debug/trace/{event_id}") +async def get_event_trace(event_id: str): + """Get trace for a specific event - returns array of Span objects""" + exporter = get_memory_exporter() + if not exporter: + return [] + + spans = exporter.get_finished_spans() + # Filter by event_id or return recent spans + filtered = [s for s in spans if s.get("attributes", {}).get("event_id") == event_id] + return filtered if filtered else spans[-10:] + + +@debug_router.get("/traces") +async def get_traces(limit: int = 50): + """Get recent traces (OpenTelemetry)""" + exporter = get_memory_exporter() + if not exporter: + return {"traces": []} + + spans = exporter.get_finished_spans() + traces = [] + for span in spans[-limit:]: + traces.append( + { + "name": span.get("name", "unknown"), + "status": span.get("status", {}).get("code", "UNSET"), + "start_time": span.get("start_time"), + "end_time": span.get("end_time"), + "attributes": span.get("attributes", {}), + } + ) + return {"traces": traces} + + +# ---- feedback 域 ---- + + +def _feedback_state_key(response_id: str) -> str: + return str(response_id or "").strip() + + +def _feedback_payload_from_state(item: Mapping[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(item, Mapping): + return None + rating = str(item.get("Rating") or item.get("rating") or "").strip().lower() + if rating not in {"up", "down"}: + return None + return { + "AgentId": str(item.get("AgentId") or item.get("agent_id") or ""), + "SessionId": str(item.get("SessionId") or item.get("session_id") or ""), + "ResponseId": str(item.get("ResponseId") or item.get("response_id") or ""), + "EventId": str(item.get("EventId") or item.get("event_id") or ""), + "Rating": rating, + "Comment": str(item.get("Comment") or item.get("comment") or ""), + "TraceId": str(item.get("TraceId") or item.get("trace_id") or ""), + "RootSpanId": str(item.get("RootSpanId") or item.get("root_span_id") or ""), + "CreatedAt": str(item.get("CreatedAt") or item.get("created_at") or ""), + "UpdatedAt": str(item.get("UpdatedAt") or item.get("updated_at") or ""), + } + + +async def _find_feedback_assistant_event( + *, + session_id: str, + response_id: str, + event_id: str | None = None, +) -> SessionEvent | None: + events = await deps.resolve_session_service().get_events(session_id) + normalized_event_id = str(event_id or "").strip() + normalized_response_id = str(response_id or "").strip() + for event in reversed(events): + if normalized_event_id and event.id != normalized_event_id: + continue + metadata = event.metadata or {} + if ( + normalized_response_id + and str(metadata.get("response_id") or "") != normalized_response_id + ): + continue + event_type = deps.conversation().canonical_event_type( + event.event_type, + author=event.author, + role=str((event.content or {}).get("role") or ""), + ) + if event_type == "assistant_message": + return cast(SessionEvent, event) + return None + + +@feedback_router.post("/agentengine/api/v1/GetResponseFeedback") +async def get_response_feedback_action(request: ResponseFeedbackRefActionRequest): + session = await deps.resolve_session_service().get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + return _action_response("GetResponseFeedback", {"Feedback": None}) + feedbacks = session.state.get("__ksadk_response_feedback__") + feedback = None + if isinstance(feedbacks, Mapping): + feedback = _feedback_payload_from_state( + feedbacks.get(_feedback_state_key(request.ResponseId)) + ) + return _action_response("GetResponseFeedback", {"Feedback": feedback}) + + +@feedback_router.post("/agentengine/api/v1/UpsertResponseFeedback") +async def upsert_response_feedback_action(request: UpsertResponseFeedbackActionRequest): + rating = str(request.Rating or "").strip().lower() + if rating not in {"up", "down"}: + raise HTTPException(status_code=400, detail="Feedback rating must be up or down") + + service = deps.resolve_session_service() + session = await service.get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + raise HTTPException(status_code=404, detail="Session not found") + + assistant_event = await _find_feedback_assistant_event( + session_id=request.SessionId, + response_id=request.ResponseId, + event_id=request.EventId, + ) + if assistant_event is None: + raise HTTPException(status_code=404, detail="Assistant response not found") + + now = str(time.time()) + existing_feedbacks = session.state.get("__ksadk_response_feedback__") + feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {} + existing = ( + _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) or {} + ) + metadata = assistant_event.metadata or {} + feedback = { + "AgentId": request.AgentId, + "SessionId": request.SessionId, + "ResponseId": request.ResponseId, + "EventId": request.EventId or assistant_event.id, + "Rating": rating, + "Comment": request.Comment or "", + "TraceId": request.TraceId or str(metadata.get("trace_id") or ""), + "RootSpanId": request.RootSpanId or str(metadata.get("root_span_id") or ""), + "CreatedAt": existing.get("CreatedAt") or now, + "UpdatedAt": now, + } + feedbacks[_feedback_state_key(request.ResponseId)] = feedback + await service.update_state( + agent_id=session.agent_id, + user_id=session.user_id, + session_id=session.id, + scope="session", + state_delta={"__ksadk_response_feedback__": feedbacks}, + ) + return _action_response("UpsertResponseFeedback", {"Feedback": feedback}) + + +@feedback_router.post("/agentengine/api/v1/DeleteResponseFeedback") +async def delete_response_feedback_action(request: ResponseFeedbackRefActionRequest): + service = deps.resolve_session_service() + session = await service.get_session(request.SessionId) + if not session or session.agent_id != request.AgentId: + return _action_response("DeleteResponseFeedback", {"Deleted": False}) + existing_feedbacks = session.state.get("__ksadk_response_feedback__") + feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {} + deleted = feedbacks.pop(_feedback_state_key(request.ResponseId), None) is not None + if deleted: + await service.update_state( + agent_id=session.agent_id, + user_id=session.user_id, + session_id=session.id, + scope="session", + state_delta={"__ksadk_response_feedback__": feedbacks}, + ) + return _action_response("DeleteResponseFeedback", {"Deleted": deleted}) + + +# ---- sessions_adk_compat 域(ADK-Web 兼容)---- + + +@sessions_adk_compat_router.post("/apps/{app_name}/users/{user_id}/sessions") +async def create_session(app_name: str, user_id: str, request: Request): + """Create a new session""" + # Check if importing existing events + body = {} + try: + body = await request.json() + except Exception: + pass + + service = deps.resolve_session_service() + session = await _ensure_session(app_name, user_id, body.get("sessionId") or body.get("id")) + + for raw_event in body.get("events", []): + session_event = SessionEvent.from_dict(raw_event, session_id=session.id) + await service.append_event(session.id, session_event) + + hydrated = await _hydrate_session(await service.get_session(session.id)) + return hydrated.to_legacy_dict() if hydrated else session.to_legacy_dict() + + +@sessions_adk_compat_router.get("/apps/{app_name}/users/{user_id}/sessions") +async def list_sessions(app_name: str, user_id: str): + """List all sessions for a user""" + service = deps.resolve_session_service() + sessions = await service.list_sessions(app_name, user_id) + hydrated: List[Dict[str, Any]] = [] + for session in sessions: + session.events = await service.get_events(session.id) + hydrated.append(session.to_legacy_dict()) + return hydrated + + +@sessions_adk_compat_router.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}") +async def get_session(app_name: str, user_id: str, session_id: str): + """Get a specific session with its events""" + service = deps.resolve_session_service() + session = await _hydrate_session(await service.get_session(session_id)) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session.to_legacy_dict() + + +@sessions_adk_compat_router.delete("/apps/{app_name}/users/{user_id}/sessions/{session_id}") +async def delete_session(app_name: str, user_id: str, session_id: str): + """Delete a session""" + service = deps.resolve_session_service() + if await service.delete_session(session_id): + return {"status": "deleted"} + raise HTTPException(status_code=404, detail="Session not found") + + +@sessions_adk_compat_router.post( + "/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, + ) + + 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" +) +async def get_event_graph(app_name: str, user_id: str, session_id: str, event_id: str): + """Get event graph (DOT format) - placeholder""" + return {"dotSrc": None} + + +@sessions_adk_compat_router.get("/apps/{app_name}/eval_sets") +async def list_eval_sets(app_name: str): + """List evaluation sets - stub for ADK-Web""" + return [] + + +@sessions_adk_compat_router.get("/apps/{app_name}/eval_results") +async def list_eval_results(app_name: str): + """List evaluation results - stub for ADK-Web""" + return [] diff --git a/ksadk/server/routes/models.py b/ksadk/server/routes/models.py new file mode 100644 index 00000000..a86acfda --- /dev/null +++ b/ksadk/server/routes/models.py @@ -0,0 +1,443 @@ +"""Runtime HTTP request models and pure projection helpers.""" + +from __future__ import annotations + +import os +from datetime import datetime, timezone +from typing import Any, Dict, List, Mapping, Optional + +from fastapi import HTTPException +from pydantic import BaseModel, Field, field_validator + +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.sessions import SessionEvent + +_RUN_TERMINAL_STATUSES = RUN_STATUS_TERMINAL +_RUN_ACTIVE_STATUSES = RUN_STATUS_ACTIVE +_EVENT_SCAN_PAGE_SIZE = 500 +_MAX_PREVIEW_TOOL_RECEIPTS = 500 +_RUNTIME_RUN_STATUS_BY_EVENT_TYPE = { + "run.started": "in_progress", + "run.progress": "in_progress", + "run.interrupted": "interrupted", + "run.completed": "completed", + "run.failed": "failed", + "run.canceled": "cancelled", +} + + +def _parse_iso_datetime(value: Any) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + if raw.endswith("Z"): + raw = f"{raw[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(raw) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +class UiBootstrapRequest(BaseModel): + AgentId: Optional[str] = None + SessionId: Optional[str] = None + + +class CreateSessionActionRequest(BaseModel): + AgentId: str + UserId: Optional[str] = "user" + SessionId: Optional[str] = None + + +class ListSessionsActionRequest(BaseModel): + AgentId: str + UserId: Optional[str] = None + Page: int = Field(1, ge=1) + PageSize: int = Field(20, ge=1, le=200) + + +class SessionIdRequest(BaseModel): + SessionId: str + AgentId: Optional[str] = None + UserId: Optional[str] = None + + +class ListSessionEventsActionRequest(BaseModel): + AgentId: Optional[str] = None + SessionId: Optional[str] = None + UserId: Optional[str] = None + Offset: Optional[int] = Field(None, ge=0) + Limit: int = Field(200, ge=1, le=2000) + AfterSeqId: Optional[int] = Field(None, ge=0) + BeforeSeqId: Optional[int] = Field(None, ge=1) + + +class ListSessionMessagesActionRequest(BaseModel): + AgentId: Optional[str] = None + UserId: Optional[str] = None + SessionId: str + AfterSeqId: Optional[int] = Field(None, ge=0) + BeforeSeqId: Optional[int] = Field(None, ge=1) + Limit: int = Field(50, ge=1, le=200) + IncludeReasoning: bool = False + IncludeToolEvents: bool = False + IncludeAttachments: bool = True + + +class ListSessionCheckpointsActionRequest(BaseModel): + AgentId: str + SessionId: Optional[str] = None + UserId: Optional[str] = None + RunId: Optional[str] = None + OnlyResumable: bool = False + Framework: Optional[str] = None + Offset: Optional[int] = Field(None, ge=0) + Limit: int = Field(100, ge=1, le=500) + + +class ListToolReceiptsActionRequest(BaseModel): + AgentId: str + UserId: Optional[str] = None + SessionId: str + RunId: Optional[str] = None + CheckpointId: Optional[str] = None + Offset: int = Field(0, ge=0) + Limit: int = Field(200, ge=1, le=500) + + +class ResumeRunActionRequest(BaseModel): + AgentId: str + UserId: Optional[str] = None + SessionId: str + RunId: str + CheckpointId: str + ResumeAttemptId: Optional[str] = None + InvocationId: Optional[str] = None + Stream: bool = False + Model: Optional[str] = None + ModelMetadata: Optional[Dict[str, Any]] = None + ModelOptions: Optional[Dict[str, Any]] = None + Metadata: Optional[Dict[str, Any]] = None + ResumeInstructionEnabled: bool = False + ResumeInstruction: Optional[str] = None + + +class GetCheckpointResumePreviewActionRequest(BaseModel): + AgentId: str + UserId: Optional[str] = None + SessionId: str + RunId: str + CheckpointId: str + + +class RunAgentActionRequest(BaseModel): + AgentId: str + Messages: List[Dict[str, Any]] = Field(default_factory=list) + UserId: Optional[str] = "user" + AccountId: Optional[str] = None + SessionId: Optional[str] = None + InvocationId: Optional[str] = None + ApiFormat: str = "responses" + Stream: bool = False + Background: bool = False # 立即返回 job 句柄,后台执行,进度走 SubscribeRunEvents + Model: Optional[str] = None + ModelMetadata: Optional[Dict[str, Any]] = None + ModelOptions: Optional[Dict[str, Any]] = None + Metadata: Optional[Dict[str, Any]] = None + ResponsesInput: Optional[Any] = None + PreviousResponseId: Optional[str] = None + + +class ResponseFeedbackRefActionRequest(BaseModel): + AgentId: str + SessionId: str + ResponseId: str + + +class UpsertResponseFeedbackActionRequest(ResponseFeedbackRefActionRequest): + Rating: str + Comment: Optional[str] = "" + EventId: Optional[str] = None + TraceId: Optional[str] = None + RootSpanId: Optional[str] = None + + +class ResponsesRequest(BaseModel): + input: Any + model: Optional[str] = None + model_metadata: Optional[Dict[str, Any]] = None + model_options: Optional[Dict[str, Any]] = None + instructions: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + conversation: Optional[Any] = None + safety_identifier: Optional[str] = None + prompt_cache_key: Optional[str] = None + user: Optional[str] = None + account_id: Optional[str] = None + store: Optional[bool] = None + previous_response_id: Optional[str] = None + stream: bool = False + session_id: Optional[str] = None + + @field_validator("metadata") + @classmethod + def validate_metadata(cls, value: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if value is None: + return None + public_items = [(key, item) for key, item in value.items() if key != "agentengine"] + if len(public_items) > 16: + raise ValueError("Responses metadata supports at most 16 key-value pairs") + for key, item in public_items: + if len(key) > 64: + raise ValueError("Responses metadata keys must be at most 64 characters") + if not isinstance(item, str): + raise ValueError("Responses metadata values must be strings") + if len(item) > 512: + raise ValueError("Responses metadata values must be at most 512 characters") + return value + + +class WorkspaceListActionRequest(BaseModel): + AgentId: Optional[str] = None + Path: str = "." + Recursive: bool = False + + +def _clean_optional_string(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _resolve_responses_conversation_id(conversation_value: Any) -> str | None: + if conversation_value is None: + return None + if isinstance(conversation_value, str): + return _clean_optional_string(conversation_value) + if isinstance(conversation_value, Mapping): + return _clean_optional_string(conversation_value.get("id")) + raise HTTPException( + status_code=400, + detail="Responses field 'conversation' must be a string or an object with an 'id'.", + ) + + +def _resolve_responses_session_and_user(request: ResponsesRequest) -> tuple[str | None, str]: + conversation_id = _resolve_responses_conversation_id(request.conversation) + legacy_session_id = _clean_optional_string(request.session_id) + + if conversation_id and legacy_session_id and conversation_id != legacy_session_id: + raise HTTPException( + status_code=400, + detail=( + "Responses field 'conversation' conflicts with ksadk legacy field " + "'session_id'. Use 'conversation' for OpenAI-compatible calls." + ), + ) + if conversation_id and request.previous_response_id: + raise HTTPException( + status_code=400, + detail=( + "Responses fields 'conversation' and 'previous_response_id' cannot be " + "used together." + ), + ) + + resolved_session_id = conversation_id or legacy_session_id + resolved_user_id = ( + _clean_optional_string(request.safety_identifier) + or _clean_optional_string(request.user) + or "user" + ) + return resolved_session_id, resolved_user_id + + +def _runtime_agent_id(active_runner: BaseRunner) -> 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") + + +def _metadata_invocation_id(metadata: Mapping[str, Any] | None) -> str | None: + if not isinstance(metadata, Mapping): + return None + agentengine_metadata = metadata.get("agentengine") + if not isinstance(agentengine_metadata, Mapping): + return None + return _clean_optional_string(agentengine_metadata.get("invocation_id")) + + +def _split_custom_metadata( + metadata: Mapping[str, Any] | None, +) -> tuple[dict[str, Any], dict[str, Any]]: + public_metadata = dict(metadata or {}) + runtime_metadata: dict[str, Any] = {} + agentengine_metadata = public_metadata.get("agentengine") + public_metadata.pop("agentengine", None) + if isinstance(agentengine_metadata, Mapping): + runtime_metadata["agentengine"] = dict(agentengine_metadata) + # The approval profile is a small, validated-by-the-agent runtime control. + # It must not be mixed into caller-visible public metadata. + tool_approval_mode = agentengine_metadata.get("tool_approval_mode") + if isinstance(tool_approval_mode, str): + runtime_metadata["tool_approval_mode"] = tool_approval_mode + return public_metadata, runtime_metadata + + +def _run_agent_response_metadata( + custom_metadata: Mapping[str, Any] | None, + result: Mapping[str, Any] | None, +) -> dict[str, Any]: + response_metadata = dict(custom_metadata or {}) + result_metadata = result.get("metadata") if isinstance(result, Mapping) else None + if isinstance(result_metadata, Mapping): + agentengine_metadata = result_metadata.get("agentengine") + if isinstance(agentengine_metadata, Mapping): + response_metadata["agentengine"] = dict(agentengine_metadata) + return response_metadata + + +def _event_text(event: SessionEvent) -> str: + parts = (event.content or {}).get("parts") + if isinstance(parts, list): + text_parts: list[str] = [] + for part in parts: + if isinstance(part, Mapping): + text_parts.append(str(part.get("text") or "")) + else: + text_parts.append(str(part or "")) + text = "".join(text_parts).strip() + if text: + return text + return str((event.content or {}).get("text") or "").strip() + + +def _truncate_session_text(text: str, limit: int = 512) -> str: + normalized = " ".join(str(text or "").strip().split()) + if len(normalized) <= limit: + return normalized + return f"{normalized[: max(limit - 1, 0)].rstrip()}…" + + +def _session_user_prompt_from_event(event: SessionEvent) -> str: + metadata = event.metadata or {} + content = event.content or {} + return str( + metadata.get("agent_input") + or metadata.get("user_input") + or content.get("agent_input") + or _event_text(event) + or "" + ).strip() + + +def _run_status_payload_status(event: SessionEvent) -> str: + legacy_status = str( + (event.metadata or {}).get("status") + or (event.metadata or {}).get("run_status") + or (event.content or {}).get("status") + or "" + ).strip() + if legacy_status: + return legacy_status + runtime_payload = (event.content or {}).get("payload") + runtime_status = ( + str(runtime_payload.get("status") or "").strip() + if isinstance(runtime_payload, Mapping) + else "" + ) + return runtime_status or _RUNTIME_RUN_STATUS_BY_EVENT_TYPE.get(event.event_type, "") + + +def _is_run_lifecycle_event(event: SessionEvent) -> bool: + return event.event_type == "run_status" or event.event_type in _RUNTIME_RUN_STATUS_BY_EVENT_TYPE + + +def _event_run_id(event: SessionEvent) -> str: + return str( + (event.metadata or {}).get("run_id") + or (event.metadata or {}).get("invocation_id") + or event.invocation_id + or "" + ).strip() + + +def _session_topic_from_events(events: list[SessionEvent]) -> str: + for event in reversed(events): + metadata = event.metadata or {} + tool_output = metadata.get("tool_output") + if isinstance(tool_output, Mapping): + topic = str(tool_output.get("topic") or tool_output.get("research_title") or "").strip() + if topic: + return topic + topic = str(metadata.get("research_title") or metadata.get("task_title") or "").strip() + if topic: + return topic + return "" + + +def _latest_session_run_status(events: list[SessionEvent]) -> tuple[str, str]: + latest_by_invocation: dict[str, tuple[str, SessionEvent]] = {} + for event in reversed(events): + if not _is_run_lifecycle_event(event): + continue + status = _run_status_payload_status(event) + invocation_id = _event_run_id(event) + if status or invocation_id: + latest_by_invocation.setdefault(invocation_id, (status, event)) + for invocation_id, (status, _) in latest_by_invocation.items(): + if status in _RUN_ACTIVE_STATUSES: + return invocation_id, status + for invocation_id, (status, _) in latest_by_invocation.items(): + if status not in _RUN_TERMINAL_STATUSES: + return invocation_id, status + if latest_by_invocation: + invocation_id, (status, _) = next(iter(latest_by_invocation.items())) + return invocation_id, status + for event in reversed(events): + invocation_id = _event_run_id(event) + if invocation_id: + return invocation_id, "" + return "", "" + + +def _latest_session_run_metadata( + events: list[SessionEvent], +) -> tuple[str, str, str, str]: + """返回 (invocation_id, status, run_mode, run_trigger)。 + + 与 _latest_session_run_status 同语义,但额外从最新 run_status 事件的 metadata + 读取 run_mode/run_trigger。旧事件缺字段降级 unknown。原 _latest_session_run_status + 不动,保护现有 ActiveInvocationId/ActiveRunStatus 契约。 + """ + invocation_id, status = _latest_session_run_status(events) + run_mode = RUN_MODE_UNKNOWN + run_trigger = RUN_TRIGGER_UNKNOWN + if invocation_id: + for event in reversed(events): + 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 + return invocation_id, status, run_mode, run_trigger + + +class WorkspaceDeleteActionRequest(BaseModel): + AgentId: Optional[str] = None + Path: str + + +class CancelRunActionRequest(BaseModel): + AgentId: Optional[str] = None + UserId: Optional[str] = None + SessionId: Optional[str] = None + InvocationId: str diff --git a/ksadk/server/routes/openai_compat.py b/ksadk/server/routes/openai_compat.py new file mode 100644 index 00000000..c8d2688c --- /dev/null +++ b/ksadk/server/routes/openai_compat.py @@ -0,0 +1,207 @@ +"""OpenAI Responses and Chat Completions compatibility routes.""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from typing import Any, Dict, List, Optional + +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ksadk.conversations.run_kinds import RUN_MODE_FOREGROUND, trigger_from_resume_input + +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, + _metadata_invocation_id, + _resolve_responses_session_and_user, + _runtime_agent_id, + _split_custom_metadata, +) +from .routers import openai_compat_router +from .streaming import _detached_resume_key_from_input, _reject_if_detached_resume_active +from .workspace import _build_models_payload + + +class ChatCompletionRequest(BaseModel): + messages: List[Dict[str, Any]] + model: Optional[str] = None + model_metadata: Optional[Dict[str, Any]] = None + model_options: Optional[Dict[str, Any]] = None + stream: bool = False + session_id: Optional[str] = None + user: Optional[str] = None + account_id: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + temperature: Optional[float] = 0.7 + max_tokens: Optional[int] = None + + +@openai_compat_router.get("/v1/models") +async def list_openai_models(): + """Expose the current model catalog through the OpenAI-compatible path.""" + + payload = await _build_models_payload() + return { + "object": "list", + "data": payload.get("data", []), + "current": payload.get("current"), + "source": payload.get("source", ""), + } + + +@openai_compat_router.post("/v1/responses") +async def responses(request: ResponsesRequest): + """OpenAI Responses 兼容接口。""" + active_runner = _resolve_active_runner() + resolved_session_id, resolved_user_id = _resolve_responses_session_and_user(request) + agent_id = _runtime_agent_id(active_runner) + + resume_input = deps.conversation().extract_responses_resume_input(request.input) + resume_input = await _resolve_checkpoint_resume_input_from_session( + service=deps.resolve_session_service(), + agent_id=agent_id, + session_id=resolved_session_id, + resume_input=resume_input, + ) + messages = ( + [] + if resume_input is not None + else deps.conversation().normalize_responses_input(request.input) + ) + custom_metadata, request_metadata = _split_custom_metadata(request.metadata) + if request.previous_response_id: + request_metadata["previous_response_id"] = request.previous_response_id + if request.prompt_cache_key: + request_metadata["prompt_cache_key"] = request.prompt_cache_key + if request.safety_identifier: + request_metadata["safety_identifier"] = request.safety_identifier + if request.user: + request_metadata["user"] = request.user + if request.conversation is not None: + request_metadata["conversation"] = request.conversation + if request.store is not None: + request_metadata["store"] = request.store + account_id = _clean_optional_string(request.account_id) + invocation_id = _metadata_invocation_id(request_metadata) + + if request.stream: + 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, + agent_id=agent_id, + user_id=resolved_user_id, + messages=messages, + session_id=resolved_session_id, + model=request.model, + model_metadata=request.model_metadata, + model_options=request.model_options, + instructions=request.instructions, + request_metadata=request_metadata, + custom_metadata=custom_metadata, + 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, + ), + invocation_id=invocation_id, + resume_key=resume_key, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=trigger_from_resume_input(resume_input), + ) + + response_id = f"resp_{uuid.uuid4().hex}" + resolved_session_id, result = await deps.conversation().invoke_conversation_once( + runner=active_runner, + agent_id=agent_id, + user_id=resolved_user_id, + messages=messages, + session_id=resolved_session_id, + model=request.model, + model_metadata=request.model_metadata, + model_options=request.model_options, + instructions=request.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, + prepare_runner=_prepare_runner_for_model, + session_service_provider=deps.resolve_session_service, + run_mode=RUN_MODE_FOREGROUND, + ) + return deps.conversation().build_responses_payload( + output_text=result["output_text"], + model=request.model, + session_id=resolved_session_id, + response_id=response_id, + metadata=custom_metadata, + usage=result.get("usage") if isinstance(result.get("usage"), Mapping) else None, + ) + + +@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) + 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: + return StreamingResponse( + deps.conversation().stream_conversation_turn( + runner=active_runner, + agent_id=agent_id, + user_id=resolved_user_id, + messages=messages, + session_id=request.session_id, + model=request.model, + model_metadata=request.model_metadata, + model_options=request.model_options, + request_metadata=request_metadata, + 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, + ), + media_type="text/event-stream", + ) + + resolved_session_id, result = await deps.conversation().invoke_conversation_once( + runner=active_runner, + agent_id=agent_id, + user_id=resolved_user_id, + messages=messages, + session_id=request.session_id, + model=request.model, + model_metadata=request.model_metadata, + model_options=request.model_options, + request_metadata=request_metadata, + 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( + output_text=result["output_text"], + model=request.model, + session_id=resolved_session_id, + metadata=result.get("metadata"), + ) diff --git a/ksadk/server/routes/projection.py b/ksadk/server/routes/projection.py new file mode 100644 index 00000000..beed2aed --- /dev/null +++ b/ksadk/server/routes/projection.py @@ -0,0 +1,795 @@ +"""Session event projection, pagination, and checkpoint policy helpers.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator, Mapping +from datetime import datetime, timezone +from typing import Any, Optional, cast + +from fastapi import HTTPException + +from ksadk.conversations.session_title import ( + HEURISTIC_SESSION_TITLE_SOURCE, + build_fallback_title, + build_heuristic_title, +) +from ksadk.server.factory import get_state +from ksadk.sessions import ConversationSessionCore, Session, SessionEvent + +from . import dependencies as deps +from .common import _sanitize_session_state_for_action +from .models import ( + _EVENT_SCAN_PAGE_SIZE, + _latest_session_run_metadata, + _parse_iso_datetime, + _session_topic_from_events, + _session_user_prompt_from_event, + _truncate_session_text, +) + +logger = logging.getLogger(__name__) + + +async def _require_action_session( + service, + *, + session_id: str, + agent_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> Session: + """Resolve one runtime session while enforcing every supplied scope.""" + + session = await service.get_session_metadata(str(session_id or "").strip()) + if ( + session is None + or (agent_id is not None and session.agent_id != agent_id) + or (user_id is not None and session.user_id != user_id) + ): + raise HTTPException(status_code=404, detail="Session not found") + return cast(Session, 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: + events = await deps.resolve_session_service().get_events(session.id) + except Exception as exc: + logger.debug("Failed to hydrate events for session %s: %s", session.id, exc) + events = [] + event_prompts = [ + _session_user_prompt_from_event(event) + for event in events + if event.event_type == "user_message" + ] + event_prompts = [prompt for prompt in event_prompts if prompt] + first_prompt = session.first_prompt or (event_prompts[0] if event_prompts else "") + last_prompt = session.last_prompt or (event_prompts[-1] if event_prompts else "") + ( + active_invocation_id, + active_run_status, + active_run_mode, + active_run_trigger, + ) = _latest_session_run_metadata(events) + title = session.title + title_source = session.title_source + if not title: + title_seed = _session_topic_from_events(events) or first_prompt + if title_seed: + title = build_fallback_title(title_seed) + title_source = "fallback_first_prompt" + if title_source == "fallback_first_prompt": + heuristic = build_heuristic_title( + first_prompt=first_prompt or title, + assistant_text=session.summary or "", + ) + if heuristic and heuristic != title: + title = heuristic + title_source = HEURISTIC_SESSION_TITLE_SOURCE + payload = { + "SessionId": session.id, + "AgentId": session.agent_id, + "UserId": session.user_id, + "Title": title, + "TitleSource": title_source, + "Summary": session.summary, + "FirstPrompt": _truncate_session_text(first_prompt), + "LastPrompt": _truncate_session_text(last_prompt), + "ActiveInvocationId": active_invocation_id, + "ActiveRunStatus": active_run_status, + "ActiveRunMode": active_run_mode, + "ActiveRunTrigger": active_run_trigger, + "State": _sanitize_session_state_for_action(session.state), + "CreatedAt": session.created_at, + "UpdatedAt": session.updated_at, + "Version": session.version, + } + 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 _event_to_action_payload(event: SessionEvent) -> dict[str, Any]: + payload = { + "EventId": event.id, + "SessionId": event.session_id, + "Author": event.author, + "EventType": event.event_type, + "Content": event.content, + "Timestamp": event.timestamp, + "SeqId": event.seq_id, + "Metadata": event.metadata, + } + if event.invocation_id: + payload["InvocationId"] = event.invocation_id + return payload + + +async def _iter_with_idle_heartbeat(source: AsyncIterator[Any]): + """转发 source 的 chunk,空闲超时发 ``: ping`` 而不取消 source 迭代器。 + + 直接用 ``asyncio.wait_for(source.__anext__(), timeout=...)`` 会在超时时 cancel + 掉正在进行的 ``__anext__()``;若 source 是原生 async generator,cancel 会触发 + GeneratorExit 把它关闭,下一轮 ``__anext__`` 抛 StopAsyncIteration → 流提前断。 + 用 pump task + queue 隔离:pump 独立消费 source,主循环只对 queue.get() 计时, + 超时时发心跳而不碰 source。 + """ + queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=1) + + async def pump() -> None: + async for chunk in source: + await queue.put(chunk) + + pump_task = asyncio.create_task(pump()) + get_task: asyncio.Task[Any] | None = None + try: + while True: + # Drain queued data before observing producer completion so a final + # chunk is never lost when the producer exits immediately after it. + if not queue.empty(): + yield ("chunk", queue.get_nowait()) + continue + if pump_task.done(): + pump_task.result() + return + + get_task = asyncio.create_task(queue.get()) + done, _ = await asyncio.wait( + {get_task, pump_task}, + timeout=deps.heartbeat_interval(), + return_when=asyncio.FIRST_COMPLETED, + ) + if get_task in done: + chunk = get_task.result() + get_task = None + yield ("chunk", chunk) + continue + if pump_task in done: + # The final queue.put() may have completed in the same loop turn + # as the producer. Let the pending getter consume it first. + if not queue.empty(): + chunk = await get_task + get_task = None + yield ("chunk", chunk) + continue + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + get_task = None + pump_task.result() + return + + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + get_task = None + yield ("heartbeat", None) + finally: + if get_task is not None and not get_task.done(): + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + if not pump_task.done(): + pump_task.cancel() + await asyncio.gather(pump_task, return_exceptions=True) + + +def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: + if event.event_type != "run_checkpoint": + 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() + framework_ref = metadata.get("framework_ref") + if not run_id or not checkpoint_id or not framework or not isinstance(framework_ref, Mapping): + return None + next_node = str(metadata.get("next_node") or "").strip() + if not next_node: + langgraph_ref = framework_ref.get("langgraph") + if isinstance(langgraph_ref, Mapping): + next_node = str(langgraph_ref.get("next_node") or "").strip() + is_terminal = bool(metadata.get("is_terminal", False)) + is_resumable_raw = metadata.get("is_resumable") + is_resumable = is_resumable_raw if isinstance(is_resumable_raw, bool) else None + backend = str(metadata.get("backend") or "unknown").strip() or "unknown" + scope = str(metadata.get("scope") or "unknown").strip() or "unknown" + durable = bool(metadata.get("durable", False)) + disabled_reason = str(metadata.get("resume_disabled_reason") or "").strip() + if is_terminal: + is_resumable = False + disabled_reason = disabled_reason or "该 checkpoint 已是终态;可选择更早恢复点重跑" + if backend == "memory" or scope == "process_local": + is_resumable = False + disabled_reason = disabled_reason or "进程内 checkpoint 不能跨实例恢复" + resume_status = str(metadata.get("resume_status") or "").strip() + if not resume_status: + if is_resumable is True: + resume_status = "resumable" + elif is_resumable is False: + resume_status = "disabled" + else: + resume_status = "unknown" + if resume_status == "disabled" and not disabled_reason: + disabled_reason = "该 checkpoint 不可恢复" + artifact_preview = metadata.get("artifact_preview") + if not isinstance(artifact_preview, Mapping): + artifact_preview = {} + resume_count_raw = metadata.get("resume_count") + try: + resume_count = int(resume_count_raw) if resume_count_raw is not None else 0 + except (TypeError, ValueError): + resume_count = 0 + last_resumed_at = metadata.get("last_resumed_at") + replay_allowed_raw = metadata.get("replay_allowed") + replay_allowed = replay_allowed_raw if isinstance(replay_allowed_raw, bool) else True + expires_at = metadata.get("expires_at") + checkpoint_status = str(metadata.get("checkpoint_status") or "").strip() + if not checkpoint_status: + if is_terminal: + checkpoint_status = "resumed" if resume_count else "terminal" + elif is_resumable is False: + checkpoint_status = "disabled" + else: + checkpoint_status = "active" + payload = { + "EventId": event.id, + "SessionId": event.session_id, + "InvocationId": event.invocation_id, + "SeqId": event.seq_id, + "Timestamp": event.timestamp, + "RunId": run_id, + "CheckpointId": checkpoint_id, + "Framework": framework, + "FrameworkRef": dict(framework_ref), + "Phase": str(metadata.get("phase") or ""), + "Metadata": metadata, + "IsResumable": is_resumable, + "ResumeStatus": resume_status, + "IsTerminal": is_terminal, + "ResumeDisabledReason": disabled_reason, + "NextNode": next_node, + "StageKey": str(metadata.get("stage_key") or ""), + "StageName": str( + metadata.get("stage_name") or metadata.get("stage") or metadata.get("title") or "" + ), + "StageIndex": metadata.get("stage_index"), + "TotalStages": metadata.get("total_stages"), + "Backend": backend, + "Scope": scope, + "Durable": durable, + "CreatedAt": event.timestamp, + "ArtifactPreview": dict(artifact_preview), + "LastResumedAt": last_resumed_at, + "ResumeCount": resume_count, + "ReplayAllowed": replay_allowed, + "ExpiresAt": expires_at, + "CheckpointStatus": checkpoint_status, + } + stage = str(metadata.get("stage") or metadata.get("title") or "").strip() + summary = str(metadata.get("summary") or metadata.get("description") or "").strip() + next_action = str(metadata.get("next_action") or metadata.get("nextAction") or "").strip() + status = str(metadata.get("status") or "").strip() + if stage: + payload["Stage"] = stage + if summary: + payload["Summary"] = summary + if next_action: + payload["NextAction"] = next_action + if status: + payload["Status"] = status + return payload + + +def _resume_audit_by_checkpoint( + events: list[SessionEvent], +) -> dict[tuple[str, str], dict[str, Any]]: + audit: dict[tuple[str, str], dict[str, Any]] = {} + for event in events: + if event.event_type != "run_resume": + continue + metadata = event.metadata or {} + run_id = str(metadata.get("run_id") or "").strip() + checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() + if not run_id or not checkpoint_id: + continue + key = (run_id, checkpoint_id) + item = audit.setdefault(key, {"resume_count": 0, "last_resumed_at": None}) + item["resume_count"] = int(item["resume_count"]) + 1 + item["last_resumed_at"] = event.timestamp + return audit + + +def _apply_checkpoint_resume_audit( + checkpoint: dict[str, Any], + audit_by_checkpoint: Mapping[tuple[str, str], Mapping[str, Any]], +) -> dict[str, Any]: + audit = audit_by_checkpoint.get( + ( + str(checkpoint.get("RunId") or ""), + str(checkpoint.get("CheckpointId") or ""), + ), + {}, + ) + metadata = dict(checkpoint.get("Metadata") or {}) + resume_count = int(audit.get("resume_count") or checkpoint.get("ResumeCount") or 0) + last_resumed_at = audit.get("last_resumed_at") or checkpoint.get("LastResumedAt") + checkpoint["ResumeCount"] = resume_count + checkpoint["LastResumedAt"] = last_resumed_at + metadata["resume_count"] = resume_count + metadata["last_resumed_at"] = last_resumed_at + if resume_count and checkpoint.get("CheckpointStatus") in {"", "active"}: + checkpoint["CheckpointStatus"] = "resumed" + expires_at = checkpoint.get("ExpiresAt") + expires_at_dt = _parse_iso_datetime(expires_at) + if expires_at_dt is not None and expires_at_dt <= datetime.now(timezone.utc): + checkpoint["IsResumable"] = False + checkpoint["ResumeStatus"] = "disabled" + checkpoint["CheckpointStatus"] = "expired" + checkpoint["ResumeDisabledReason"] = "该 checkpoint 已过期" + elif resume_count and checkpoint.get("ReplayAllowed") is False: + checkpoint["IsResumable"] = False + checkpoint["ResumeStatus"] = "disabled" + checkpoint["ResumeDisabledReason"] = "该 checkpoint 已恢复过,当前策略不允许重复恢复" + metadata["checkpoint_status"] = checkpoint.get("CheckpointStatus") + metadata["resume_status"] = checkpoint.get("ResumeStatus") + metadata["resume_disabled_reason"] = checkpoint.get("ResumeDisabledReason") + checkpoint["Metadata"] = metadata + return checkpoint + + +def _apply_adk_only_latest_resumable( + checkpoints: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """P1.4: For ADK invocation_id resume mode, only the latest checkpoint per + RunId is independently resumable. Older checkpoints get IsResumable=False.""" + latest_by_run: dict[str, int] = {} + for cp in checkpoints: + metadata = cp.get("Metadata") or {} + if not metadata.get("only_latest_resumable"): + continue + run_id = str(cp.get("RunId") or "") + seq_id = int(cp.get("SeqId") or 0) + if run_id not in latest_by_run or seq_id > latest_by_run[run_id]: + latest_by_run[run_id] = seq_id + + for cp in checkpoints: + metadata = cp.get("Metadata") or {} + if not metadata.get("only_latest_resumable"): + continue + run_id = str(cp.get("RunId") or "") + seq_id = int(cp.get("SeqId") or 0) + if seq_id < latest_by_run.get(run_id, 0): + if cp.get("IsResumable") is True: + cp["IsResumable"] = False + cp["ResumeStatus"] = "disabled" + cp["ResumeDisabledReason"] = "新的恢复点已生成,此恢复点暂停恢复能力" + metadata["resume_disabled_reason"] = "新的恢复点已生成,此恢复点暂停恢复能力" + metadata["resume_status"] = "disabled" + cp["Metadata"] = metadata + + return checkpoints + + +def _check_adk_latest_resumable( + checkpoint: dict[str, Any], + events: list, +) -> dict[str, Any]: + """P1.4: For a single ADK only_latest_resumable checkpoint, verify it is + the latest for its RunId. If not, mark IsResumable=False.""" + metadata = checkpoint.get("Metadata") or {} + if not metadata.get("only_latest_resumable"): + return checkpoint + + run_id = str(checkpoint.get("RunId") or "") + my_seq_id = int(checkpoint.get("SeqId") or 0) + + max_seq_id = my_seq_id + for event in events: + if event.event_type != "run_checkpoint": + continue + ev_meta = event.metadata or {} + if str(ev_meta.get("run_id") or "") != run_id: + continue + seq_id = int(event.seq_id or 0) + if seq_id > max_seq_id: + max_seq_id = seq_id + + if my_seq_id < max_seq_id and checkpoint.get("IsResumable") is True: + checkpoint["IsResumable"] = False + checkpoint["ResumeStatus"] = "disabled" + checkpoint["ResumeDisabledReason"] = "新的恢复点已生成,此恢复点暂停恢复能力" + metadata["resume_disabled_reason"] = "新的恢复点已生成,此恢复点暂停恢复能力" + metadata["resume_status"] = "disabled" + checkpoint["Metadata"] = metadata + + return checkpoint + + +_SIDE_EFFECT_TOOL_NAMES = { + "write_workspace_file", + "write_workspace_files", + "delete_workspace_file", + "execute_skills", + "run_command", + "run_code", +} + + +def _tool_receipt_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None: + if event.event_type != "tool_result": + return None + metadata = event.metadata or {} + receipt = metadata.get("tool_receipt") + if not isinstance(receipt, Mapping): + return None + tool_name = str(receipt.get("tool_name") or metadata.get("tool_name") or "").strip() + if not tool_name: + return None + return { + "EventId": event.id, + "SessionId": event.session_id, + "InvocationId": event.invocation_id, + "SeqId": event.seq_id, + "Timestamp": event.timestamp, + "ReceiptId": str(receipt.get("receipt_id") or ""), + "IdempotencyKey": str(receipt.get("idempotency_key") or ""), + "ToolName": tool_name, + "ToolCallId": str(receipt.get("tool_call_id") or ""), + "RunId": str(receipt.get("run_id") or metadata.get("run_id") or ""), + "CheckpointId": str(receipt.get("checkpoint_id") or ""), + "Status": str(receipt.get("status") or ""), + "Replayed": bool(receipt.get("replayed") or metadata.get("replayed")), + "Metadata": dict(metadata), + } + + +def _build_checkpoint_resume_preview( + *, + checkpoint: Mapping[str, Any], + receipts: list[dict[str, Any]], + receipt_total: int, + side_effect_receipt_count: int, + failed_receipt_count: int, +) -> dict[str, Any]: + run_id = str(checkpoint.get("RunId") or "") + risk_level = "low" + if side_effect_receipt_count: + risk_level = "medium" + if failed_receipt_count: + risk_level = "high" + + return { + "Checkpoint": dict(checkpoint), + "Capabilities": { + "Checkpoints": True, + "CheckpointResume": checkpoint.get("IsResumable") is not False, + "ToolReceipts": True, + "IdempotentToolReplay": True, + }, + "CanResume": checkpoint.get("IsResumable") is not False, + "Reason": str(checkpoint.get("ResumeDisabledReason") or ""), + "NextNode": str(checkpoint.get("NextNode") or ""), + "ExpectedAction": ( + "resume_from_checkpoint" + if checkpoint.get("IsResumable") is True + else ("preview_required" if checkpoint.get("ResumeStatus") == "unknown" else "disabled") + ), + "ToolReceipts": receipts, + "ToolReceiptsTruncated": receipt_total > len(receipts), + "Risk": { + "Level": risk_level, + "DuplicateSideEffectRisk": side_effect_receipt_count > 0, + "SideEffectReceiptCount": side_effect_receipt_count, + "FailedReceiptCount": failed_receipt_count, + }, + "Summary": { + "RunId": run_id, + "CheckpointId": str(checkpoint.get("CheckpointId") or ""), + "Phase": str(checkpoint.get("Phase") or ""), + "ToolReceiptCount": receipt_total, + }, + } + + +def _checkpoint_resume_disabled_detail(checkpoint: Mapping[str, Any]) -> dict[str, Any] | None: + if checkpoint.get("IsResumable") is not False: + return None + reason = ( + str(checkpoint.get("ResumeDisabledReason") or "").strip() or "Checkpoint is not resumable" + ) + return { + "code": "checkpoint_not_resumable", + "reason": reason, + "checkpoint_id": str(checkpoint.get("CheckpointId") or ""), + "run_id": str(checkpoint.get("RunId") or ""), + "resume_status": str(checkpoint.get("ResumeStatus") or "disabled"), + "is_terminal": bool(checkpoint.get("IsTerminal")), + } + + +async def _iter_session_event_pages( + service: Any, + session_id: str, + *, + after_seq_id: int | None = None, + before_seq_id: int | None = None, +) -> AsyncIterator[list[SessionEvent]]: + remaining = await service.count_events( + session_id, + after_seq_id=after_seq_id, + before_seq_id=before_seq_id, + ) + while remaining > 0: + page_size = min(_EVENT_SCAN_PAGE_SIZE, remaining) + page = await service.get_events( + session_id, + offset=remaining - page_size, + limit=page_size, + after_seq_id=after_seq_id, + before_seq_id=before_seq_id, + ) + if not page: + return + yield page + remaining -= len(page) + + +def _event_invocation_id(event: SessionEvent) -> str: + return str(event.invocation_id or "").strip() + + +async def _extend_invocation_before_window( + service: Any, + session_id: str, + events: list[SessionEvent], + *, + after_seq_id: int | None, +) -> list[SessionEvent]: + if not events: + return events + invocation_id = _event_invocation_id(events[0]) + if not invocation_id: + return events + cursor = int(events[0].seq_id or 0) + prefix: list[SessionEvent] = [] + while cursor > 0: + page = await service.get_events( + session_id, + limit=_EVENT_SCAN_PAGE_SIZE, + after_seq_id=after_seq_id, + before_seq_id=cursor, + ) + if not page: + break + matching_suffix: list[SessionEvent] = [] + for event in reversed(page): + if _event_invocation_id(event) != invocation_id: + break + matching_suffix.append(event) + if not matching_suffix: + break + matching_suffix.reverse() + prefix[0:0] = matching_suffix + cursor = int(matching_suffix[0].seq_id or 0) + if len(matching_suffix) < len(page): + break + return [*prefix, *events] + + +async def _extend_invocation_after_window( + service: Any, + session_id: str, + events: list[SessionEvent], + *, + before_seq_id: int | None, +) -> list[SessionEvent]: + if not events: + return events + invocation_id = _event_invocation_id(events[-1]) + if not invocation_id: + return events + cursor = int(events[-1].seq_id or 0) + suffix: list[SessionEvent] = [] + while True: + remaining = await service.count_events( + session_id, + after_seq_id=cursor, + before_seq_id=before_seq_id, + ) + if remaining <= 0: + break + page_size = min(_EVENT_SCAN_PAGE_SIZE, remaining) + page = await service.get_events( + session_id, + offset=remaining - page_size, + limit=page_size, + after_seq_id=cursor, + before_seq_id=before_seq_id, + ) + if not page: + break + matching_prefix: list[SessionEvent] = [] + for event in page: + if _event_invocation_id(event) != invocation_id: + break + matching_prefix.append(event) + if not matching_prefix: + break + suffix.extend(matching_prefix) + cursor = int(matching_prefix[-1].seq_id or 0) + if len(matching_prefix) < len(page): + break + return [*events, *suffix] + + +async def _iter_agent_event_pages( + service: Any, + agent_id: str, + *, + user_id: str | None = None, +) -> AsyncIterator[list[SessionEvent]]: + remaining = await service.count_events_for_agent(agent_id, user_id=user_id) + while remaining > 0: + page_size = min(_EVENT_SCAN_PAGE_SIZE, remaining) + page = await service.get_events_for_agent( + agent_id, + user_id=user_id, + offset=remaining - page_size, + limit=page_size, + ) + if not page: + return + yield page + remaining -= len(page) + + +async def _iter_scoped_event_pages( + service: Any, + *, + session_id: str | None, + agent_id: str, + user_id: str | None, +) -> AsyncIterator[list[SessionEvent]]: + if session_id: + async for page in _iter_session_event_pages(service, session_id): + yield page + return + async for page in _iter_agent_event_pages(service, agent_id, user_id=user_id): + yield page + + +async def _session_contains_invocation( + service: Any, + session_id: str, + invocation_id: str, +) -> bool: + async for page in _iter_session_event_pages(service, session_id): + if any(event.invocation_id == invocation_id for event in page): + return True + return False + + +async def _agent_contains_invocation( + service: Any, + agent_id: str, + invocation_id: str, + *, + user_id: str | None, +) -> bool: + async for page in _iter_agent_event_pages(service, agent_id, user_id=user_id): + if any(event.invocation_id == invocation_id for event in page): + return True + return False + + +async def _latest_invocation_status( + service: Any, + session_id: str, + invocation_id: str, +) -> str: + offset = 0 + total = await service.count_events(session_id) + while offset < total: + page = await service.get_events( + session_id, + offset=offset, + limit=min(_EVENT_SCAN_PAGE_SIZE, total - offset), + ) + if not page: + break + for event in reversed(page): + if event.invocation_id != invocation_id or event.event_type != "run_status": + continue + return str((event.content or {}).get("status") or "").strip().lower() + offset += len(page) + return "" + + +async def _oldest_unconsumed_session_events( + service: Any, + session_id: str, + *, + after_seq_id: int, +) -> list[SessionEvent]: + remaining = await service.count_events(session_id, after_seq_id=after_seq_id) + if remaining <= 0: + return [] + page_size = min(_EVENT_SCAN_PAGE_SIZE, remaining) + events = await service.get_events( + session_id, + offset=remaining - page_size, + limit=page_size, + after_seq_id=after_seq_id, + ) + return list(events) + + +def _record_resume_audit( + audit_by_session: dict[str, dict[tuple[str, str], dict[str, Any]]], + event: SessionEvent, +) -> None: + if event.event_type != "run_resume": + return + metadata = event.metadata or {} + run_id = str(metadata.get("run_id") or "").strip() + checkpoint_id = str(metadata.get("checkpoint_id") or "").strip() + if not run_id or not checkpoint_id: + return + session_audit = audit_by_session.setdefault(event.session_id, {}) + item = session_audit.setdefault( + (run_id, checkpoint_id), + {"resume_count": 0, "last_resumed_at": None}, + ) + item["resume_count"] = int(item["resume_count"]) + 1 + item["last_resumed_at"] = event.timestamp + + +def _apply_latest_checkpoint_policy( + checkpoint: dict[str, Any], + latest_by_session_run: Mapping[tuple[str, str], int], + *, + session_id: str, +) -> dict[str, Any]: + metadata = checkpoint.get("Metadata") or {} + if not metadata.get("only_latest_resumable"): + return checkpoint + run_id = str(checkpoint.get("RunId") or "") + if int(checkpoint.get("SeqId") or 0) >= latest_by_session_run.get((session_id, run_id), 0): + return checkpoint + if checkpoint.get("IsResumable") is True: + checkpoint["IsResumable"] = False + checkpoint["ResumeStatus"] = "disabled" + checkpoint["ResumeDisabledReason"] = "新的恢复点已生成,此恢复点暂停恢复能力" + metadata["resume_disabled_reason"] = checkpoint["ResumeDisabledReason"] + metadata["resume_status"] = "disabled" + checkpoint["Metadata"] = metadata + return checkpoint diff --git a/ksadk/server/routes/routers.py b/ksadk/server/routes/routers.py new file mode 100644 index 00000000..82ed5f90 --- /dev/null +++ b/ksadk/server/routes/routers.py @@ -0,0 +1,52 @@ +"""Stable route-group registry for runtime app composition.""" + +import importlib + +from fastapi import APIRouter + +health_meta_router = APIRouter() +sessions_router = APIRouter() +sessions_adk_compat_router = APIRouter() +run_router = APIRouter() +openai_compat_router = APIRouter() +workspace_router = APIRouter() +models_router = APIRouter() +feedback_router = APIRouter() +tools_router = APIRouter() +ui_bootstrap_router = APIRouter() +control_router = APIRouter() +builder_router = APIRouter() +debug_router = APIRouter() + +GROUP_ROUTERS: dict[str, APIRouter] = { + "builder": builder_router, + "control": control_router, + "debug": debug_router, + "feedback": feedback_router, + "models": models_router, + "openai_compat": openai_compat_router, + "run": run_router, + "sessions": sessions_router, + "sessions_adk_compat": sessions_adk_compat_router, + "tools": tools_router, + "ui_bootstrap": ui_bootstrap_router, + "workspace": workspace_router, + "health_meta": health_meta_router, +} + +_ROUTE_MODULES = ( + "common", + "sessions", + "control", + "workspace", + "run", + "misc", + "openai_compat", +) + + +def load_route_modules() -> None: + """Import each domain module once so its decorators populate the routers.""" + package = __package__ or "ksadk.server.routes" + for module_name in _ROUTE_MODULES: + importlib.import_module(f"{package}.{module_name}") diff --git a/ksadk/server/routes/run.py b/ksadk/server/routes/run.py new file mode 100644 index 00000000..824d9eda --- /dev/null +++ b/ksadk/server/routes/run.py @@ -0,0 +1,726 @@ +"""RunAgent, legacy SSE, and builder-cancel execution routes.""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from fastapi.responses import StreamingResponse + +from ksadk.conversations.run_kinds import ( + RUN_MODE_BACKGROUND, + RUN_MODE_FOREGROUND, + RUN_TRIGGER_NEW_RUN, + trigger_from_resume_input, +) + +if TYPE_CHECKING: + from ksadk.conversations.runtime_payloads import PreparedConversationTurn +from ksadk.ids import new_run_id +from ksadk.server.api_models import AgentRunRequest + +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, + _clean_optional_string, + _run_agent_response_metadata, + _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, + _reject_if_detached_resume_active, +) + +logger = logging.getLogger(__name__) + + +@run_router.post("/agentengine/api/v1/RunAgent") +async def run_agent_action(request: RunAgentActionRequest): + 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) + if request.ResponsesInput is not None + else None + ) + resume_input = await _resolve_checkpoint_resume_input_from_session( + service=service, + agent_id=request.AgentId, + session_id=request.SessionId, + resume_input=resume_input, + ) + 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) + else: + messages = deps.conversation().normalize_kop_messages(request.Messages) + request_metadata: dict[str, Any] = ( + {"previous_response_id": request.PreviousResponseId} if request.PreviousResponseId else {} + ) + custom_metadata, metadata_runtime_controls = _split_custom_metadata(request.Metadata) + request_metadata.update(metadata_runtime_controls) + if api_format == "responses": + request_metadata["responses_conversation"] = True + + if request.Background: + 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( + agent_id=request.AgentId, + user_id=run_user_id, + session_id=request.SessionId, + session_service_provider=deps.resolve_session_service, + ) + resolved_background_session_id = background_session.id + if resume_input is None: + await deps.conversation().prime_session_metadata_for_user_turn( + service=service, + session=background_session, + messages=messages, + ) + await deps.conversation().append_run_status_event( + session_id=resolved_background_session_id, + author=_resolve_active_runner().detection_result.name, + status="in_progress", + invocation_id=invocation_id, + session_service_provider=deps.resolve_session_service, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=trigger_from_resume_input(resume_input), + ) + 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(), + agent_id=request.AgentId, + user_id=run_user_id, + messages=messages, + session_id=resolved_background_session_id, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata=request_metadata or None, + custom_metadata=custom_metadata, + include_agentengine_metadata=True, + 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, + ), + invocation_id=invocation_id, + session_id=resolved_background_session_id, + run_mode=RUN_MODE_BACKGROUND, + run_trigger=trigger_from_resume_input(resume_input), + ) + if invocation_id and 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 + ) + ) + return _action_response( + "RunAgent", + { + "SessionId": resolved_background_session_id, + "InvocationId": invocation_id, + "Status": "running", + "Background": True, + "SubscribeUrl": ( + "/agentengine/api/v1/SubscribeRunEvents" + f"?SessionId={resolved_background_session_id}" + f"&InvocationId={invocation_id}" + ), + }, + ) + + if request.Stream: + 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), + user_id=run_user_id, + messages=messages, + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata=request_metadata or None, + 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, + ), + 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(), + agent_id=request.AgentId, + user_id=run_user_id, + messages=messages, + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata=request_metadata or None, + custom_metadata=custom_metadata, + include_agentengine_metadata=True, + 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, + ), + invocation_id=request.InvocationId, + resume_key=resume_key, + run_mode=RUN_MODE_FOREGROUND, + run_trigger=trigger_from_resume_input(resume_input), + ) + + 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(), + agent_id=request.AgentId, + user_id=run_user_id, + messages=messages, + session_id=request.SessionId, + model=request.Model, + model_metadata=request.ModelMetadata, + model_options=request.ModelOptions, + request_metadata=request_metadata or None, + custom_metadata=custom_metadata, + resume_input=resume_input, + 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( + output_text=output_text, + model=request.Model, + session_id=resolved_session_id, + metadata=result.get("metadata"), + ) + else: + payload = deps.conversation().build_responses_payload( + output_text=output_text, + model=request.Model, + session_id=resolved_session_id, + response_id=responses_response_id, + metadata=_run_agent_response_metadata(custom_metadata, result), + usage=result.get("usage") if isinstance(result.get("usage"), Mapping) else None, + ) + return _action_response("RunAgent", payload) + + +# ============================================================ +# Session Management API (ADK Web Compatible) +# ============================================================ + + +@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) + """ + 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 [] + ) + user_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 []), + } + + 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, + ) + + async def event_generator(): + if not use_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", + 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 = { + "id": str(uuid.uuid4()), + "sessionId": session_id, + "invocationId": invocation_id, + "error": "agent_run_failed", + "errorMessage": public_error, + "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, + ) + 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, + ) + + 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 = 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, + } + ) + 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 + ) + 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", {}), + }, + ensure_ascii=False, + ) + + "\n\n" + ) + tool_event = { + "id": event_id, + "author": chunk.get("node", "tool"), + "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"], + "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 = { + "id": str(uuid.uuid4()), + "sessionId": session_id, + "invocationId": invocation_id, + "error": "agent_run_failed", + "errorMessage": public_error, + "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") + + +# ============================================================ +# Trace / Debug API (ADK Web Compatible) +# ============================================================ + + +@control_router.post("/builder/app/{app_name}/cancel") +async def cancel_agent_changes(app_name: str): + """Cancel agent builder changes - stub for ADK-Web""" + return True diff --git a/ksadk/server/routes/sessions.py b/ksadk/server/routes/sessions.py new file mode 100644 index 00000000..31ae96e7 --- /dev/null +++ b/ksadk/server/routes/sessions.py @@ -0,0 +1,649 @@ +"""Session, UI bootstrap, checkpoint-list, and tool-receipt routes.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from fastapi import HTTPException + +from ksadk.server.factory import get_runner, get_state +from ksadk.sessions import SessionEvent +from ksadk.tools.gateway import tool_approval_capability +from ksadk.toolsets import describe_agentengine_tools +from ksadk_runtime_common.workspace_files import ( + build_workspace_files_bootstrap, + workspace_files_enabled, +) + +from . import dependencies as deps +from .common import ( + _action_response, + _build_bootstrap_model_payload, + _build_native_terminal_capability, + _ensure_session, + _hydrate_session, +) +from .models import ( + CreateSessionActionRequest, + ListSessionCheckpointsActionRequest, + ListSessionEventsActionRequest, + ListSessionMessagesActionRequest, + ListSessionsActionRequest, + ListToolReceiptsActionRequest, + SessionIdRequest, + UiBootstrapRequest, + _event_run_id, + _runtime_agent_id, +) +from .projection import ( + _apply_checkpoint_resume_audit, + _apply_latest_checkpoint_policy, + _checkpoint_event_to_action_payload, + _event_invocation_id, + _event_to_action_payload, + _extend_invocation_after_window, + _extend_invocation_before_window, + _iter_scoped_event_pages, + _iter_session_event_pages, + _record_resume_audit, + _require_action_session, + _session_to_action_payload, + _tool_receipt_event_to_action_payload, +) +from .routers import sessions_router, tools_router, ui_bootstrap_router +from .streaming import _cancel_detached_streams_for_session + + +@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() + 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 {} + ) + 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 {} + ), + } + 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 + ) + responses_transport = { + "Protocol": "responses", + "Runtime": "ksadk", + "Endpoint": "/v1/responses", + "Version": "v1", + "Capabilities": { + "A2UI": False, + "Interrupt": checkpoint_resume_supported, + "Cancel": cancel_run_supported, + }, + } + hosted_chat_transports = [] + if state.agui_agent is not None and state.agui_config is not None: + from ksadk.agui.config import AGUI_PROTOCOL_VERSION + + hosted_chat_transports.append( + { + "Protocol": "ag-ui", + "Runtime": "copilotkit", + "Endpoint": state.agui_config.path, + "Version": AGUI_PROTOCOL_VERSION, + "Capabilities": { + "A2UI": True, + "Interrupt": checkpoint_resume_supported, + "Cancel": True, + }, + } + ) + hosted_chat_transports.append(responses_transport) + preferred_transport = ( + "ag-ui" + if hosted_chat_transports[0]["Protocol"] == "ag-ui" + and bool(getattr(state.agui_config, "preferred", False)) + else "responses" + ) + return _action_response( + "GetAgentUiBootstrap", + { + "Agent": { + "AgentId": agent_id, + "Name": runner.detection_result.name if runner else agent_id, + "Description": description or "", + "Framework": framework, + }, + "Modules": ["Chat", "Build", "Deploy"], + "Capabilities": { + "Attachments": True, + "WorkspaceFiles": workspace_enabled, + "Approval": True, + "ApprovalPolicy": tool_approval_capability(), + "Thinking": True, + "StopRun": cancel_run_supported, + "ResumeRun": checkpoint_resume_supported, + "RuntimeCapabilities": runtime_capabilities, + "CheckpointResumeCapability": checkpoint_resume_capability, + "RunLifecycle": { + "Enabled": True, + "Resume": True, + "Abort": True, + "Checkpoints": checkpoint_resume_supported, + "CheckpointResume": checkpoint_resume_supported, + "CheckpointResumePreview": checkpoint_resume_supported, + }, + "MCP": False, + "HostedRuntime": False, + "NativeTerminal": _build_native_terminal_capability(framework), + "BuiltinTools": describe_agentengine_tools(), + }, + "WorkspaceFiles": build_workspace_files_bootstrap(enabled=workspace_enabled), + "AccessMode": "Owner", + "SharePermissions": { + "Interactive": True, + "DefaultPath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat", + "SharePath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat", + }, + "CustomUI": { + "Enabled": bool(ui_spec.get("enabled")), + "Profile": ui_spec.get("ui_profile") or ui_spec.get("profile"), + "Path": ui_spec.get("ui_path") or ui_spec.get("path"), + "Url": ui_spec.get("ui_url") or ui_spec.get("url"), + "BundlePath": ui_spec.get("ui_bundle_path") or ui_spec.get("bundle_path"), + }, + "ApiFormats": ["responses", "chat_completions"], + "HostedChat": { + "PreferredTransport": preferred_transport, + "Transports": hosted_chat_transports, + }, + "Stream": True, + "SessionId": request.SessionId, + "SessionBackend": deps.describe_session_backend(), + "HostedRuntime": None, + "Model": _build_bootstrap_model_payload(), + }, + ) + + +@sessions_router.post("/agentengine/api/v1/CreateSession") +async def create_session_action(request: CreateSessionActionRequest): + session = await _ensure_session(request.AgentId, request.UserId or "user", request.SessionId) + return _action_response("CreateSession", {"Session": await _session_to_action_payload(session)}) + + +@sessions_router.post("/agentengine/api/v1/ListSessions") +async def list_sessions_action(request: ListSessionsActionRequest): + service = deps.resolve_session_service() + offset = (request.Page - 1) * request.PageSize + sessions = await service.list_sessions( + request.AgentId, + request.UserId, + offset=offset, + limit=request.PageSize, + ) + total = await service.count_sessions(request.AgentId, request.UserId) + session_payloads = [await _session_to_action_payload(session) for session in sessions] + return _action_response( + "ListSessions", + { + "Sessions": session_payloads, + "Total": total, + "Page": request.Page, + "PageSize": request.PageSize, + "DataSource": "runtime", + "Degraded": False, + "SessionContractVersion": 2, + }, + ) + + +@sessions_router.post("/agentengine/api/v1/GetSession") +async def get_session_action(request: SessionIdRequest): + service = deps.resolve_session_service() + session = await _require_action_session( + service, + session_id=request.SessionId, + agent_id=request.AgentId, + user_id=request.UserId, + ) + hydrated = await _hydrate_session(session) + if hydrated is None: + raise HTTPException(status_code=404, detail="Session not found") + return _action_response("GetSession", {"Session": await _session_to_action_payload(hydrated)}) + + +@sessions_router.post("/agentengine/api/v1/DeleteSession") +async def delete_session_action(request: SessionIdRequest): + service = deps.resolve_session_service() + await _require_action_session( + service, + session_id=request.SessionId, + agent_id=request.AgentId, + user_id=request.UserId, + ) + await _cancel_detached_streams_for_session(request.SessionId) + deleted = await service.delete_session(request.SessionId) + if not deleted: + raise HTTPException(status_code=404, detail="Session not found") + return _action_response("DeleteSession", {"Deleted": True}) + + +@sessions_router.post("/agentengine/api/v1/ListSessionEvents") +async def list_session_events_action(request: ListSessionEventsActionRequest): + runner = get_state().runner + 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" + ) + events = await service.get_events_for_agent( + agent_id, + user_id=request.UserId, + offset=request.Offset, + limit=request.Limit, + ) + total = await service.count_events_for_agent(agent_id, user_id=request.UserId) + return _action_response( + "ListSessionEvents", + { + "Events": [_event_to_action_payload(event) for event in events], + "Total": total, + "Offset": request.Offset or 0, + "Limit": request.Limit if request.Limit is not None else len(events), + "AfterSeqId": request.AfterSeqId, + "BeforeSeqId": request.BeforeSeqId, + "ScopedAllSessions": True, + }, + ) + await _require_action_session( + service, + session_id=session_id, + agent_id=request.AgentId, + user_id=request.UserId, + ) + events = await service.get_events( + session_id, + offset=request.Offset, + limit=request.Limit, + after_seq_id=request.AfterSeqId, + before_seq_id=request.BeforeSeqId, + ) + total = await service.count_events( + session_id, + after_seq_id=request.AfterSeqId, + before_seq_id=request.BeforeSeqId, + ) + return _action_response( + "ListSessionEvents", + { + "Events": [_event_to_action_payload(event) for event in events], + "Total": total, + "Offset": request.Offset or 0, + "Limit": request.Limit if request.Limit is not None else len(events), + "AfterSeqId": request.AfterSeqId, + "BeforeSeqId": request.BeforeSeqId, + }, + ) + + +@sessions_router.post("/agentengine/api/v1/ListSessionMessages") +async def list_session_messages_action(request: ListSessionMessagesActionRequest): + from ksadk.conversations.message_projection import project_session_messages + + service = deps.resolve_session_service() + await _require_action_session( + service, + session_id=request.SessionId, + agent_id=request.AgentId, + user_id=request.UserId, + ) + total_events = await service.count_events( + request.SessionId, + after_seq_id=request.AfterSeqId, + before_seq_id=request.BeforeSeqId, + ) + event_offset = 0 + if request.AfterSeqId is not None and total_events > 2000: + # Storage pagination is tail-based. Offset past the newest surplus so + # reconnect starts with the oldest unseen window and cannot skip gaps. + event_offset = total_events - 2000 + events = await service.get_events( + request.SessionId, + offset=event_offset, + limit=2000, + after_seq_id=request.AfterSeqId, + before_seq_id=request.BeforeSeqId, + ) + + def project( + events_to_project: list[SessionEvent], + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + serialized = [_event_to_action_payload(event) for event in events_to_project] + return serialized, project_session_messages( + serialized, + include_reasoning=request.IncludeReasoning, + include_tool_events=request.IncludeToolEvents, + include_attachments=request.IncludeAttachments, + ) + + serialized_events, messages = project(events) + if request.AfterSeqId is not None: + if total_events > len(events): + events = await _extend_invocation_after_window( + service, + request.SessionId, + events, + before_seq_id=request.BeforeSeqId, + ) + serialized_events, messages = project(events) + page = messages + has_more = total_events > len(serialized_events) + next_cursor = None + else: + page_start = len(messages) + while page_start > 0: + group_start_seq_id = int(messages[page_start - 1].get("StartSeqId") or 0) + group_start_index = next( + ( + index + for index, message in enumerate(messages[:page_start]) + if int(message.get("StartSeqId") or 0) == group_start_seq_id + ), + page_start - 1, + ) + current_size = len(messages) - page_start + group_size = page_start - group_start_index + if current_size and current_size + group_size > request.Limit: + break + page_start = group_start_index + if len(messages) - page_start >= request.Limit: + break + page = messages[page_start:] + if ( + page + and events + and total_events > len(events) + and _event_invocation_id(events[0]) + and any( + str(message.get("InvocationId") or "") == _event_invocation_id(events[0]) + for message in page + ) + ): + events = await _extend_invocation_before_window( + service, + request.SessionId, + events, + after_seq_id=request.AfterSeqId, + ) + serialized_events, messages = project(events) + page_start = len(messages) + while page_start > 0: + group_start_seq_id = int(messages[page_start - 1].get("StartSeqId") or 0) + group_start_index = next( + ( + index + for index, message in enumerate(messages[:page_start]) + if int(message.get("StartSeqId") or 0) == group_start_seq_id + ), + page_start - 1, + ) + current_size = len(messages) - page_start + group_size = page_start - group_start_index + if current_size and current_size + group_size > request.Limit: + break + page_start = group_start_index + if len(messages) - page_start >= request.Limit: + break + page = messages[page_start:] + minimum_start_seq_id = int(page[0].get("StartSeqId") or 0) if page else 0 + has_more = page_start > 0 or total_events > len(serialized_events) + # BeforeSeqId is exclusive. Use the invocation group boundary so the + # next page cannot duplicate reasoning/tools or skip its user message. + next_cursor = minimum_start_seq_id if has_more else None + latest_seq_id = ( + int(page[-1].get("SeqId") or 0) + if page + else max( + (int(event.get("SeqId") or 0) for event in serialized_events), + default=int(request.AfterSeqId or 0), + ) + ) + return _action_response( + "ListSessionMessages", + { + "SessionId": request.SessionId, + "Messages": page, + "LatestSeqId": latest_seq_id, + "HasMore": has_more, + "NextCursor": next_cursor, + }, + ) + + +def _count_resumable_checkpoints(checkpoints: list[dict[str, Any]]) -> int: + """统计可恢复 checkpoint 数量。 + + 规则:IsResumable=True AND ReplayAllowed!=False AND IsTerminal!=True + AND CheckpointStatus not in {expired, disabled}。 + 不排除 resumed(已恢复过的仍计入,符合存档点可反复读的回档语义)。 + """ + resumable = 0 + for cp in checkpoints: + if cp.get("IsResumable") is not True: + continue + if cp.get("ReplayAllowed") is False: + continue + if cp.get("IsTerminal") is True: + continue + status = str(cp.get("CheckpointStatus") or "").strip().lower() + if status in {"expired", "disabled"}: + continue + resumable += 1 + return resumable + + +async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest) -> dict[str, Any]: + service = deps.resolve_session_service() + run_id_filter = str(request.RunId or "").strip() + framework_filter = str(request.Framework or "").strip().lower() + + session_id = str(request.SessionId or "").strip() + if session_id: + await _require_action_session( + service, + session_id=session_id, + agent_id=request.AgentId, + user_id=request.UserId, + ) + + audit_by_session: dict[str, dict[tuple[str, str], dict[str, Any]]] = {} + latest_by_session_run: dict[tuple[str, str], int] = {} + run_seen = not run_id_filter + async for page in _iter_scoped_event_pages( + service, + session_id=session_id or None, + agent_id=request.AgentId, + user_id=request.UserId, + ): + for event in page: + if run_id_filter and _event_run_id(event) == run_id_filter: + run_seen = True + _record_resume_audit(audit_by_session, event) + checkpoint = _checkpoint_event_to_action_payload(event) + if checkpoint is None or not (checkpoint.get("Metadata") or {}).get( + "only_latest_resumable" + ): + continue + key = (event.session_id, str(checkpoint.get("RunId") or "")) + latest_by_session_run[key] = max( + latest_by_session_run.get(key, 0), + int(checkpoint.get("SeqId") or 0), + ) + if session_id and not run_seen: + raise HTTPException(status_code=409, detail="RunId does not belong to SessionId") + + def project_checkpoint(event: SessionEvent) -> dict[str, Any] | None: + checkpoint = _checkpoint_event_to_action_payload(event) + if checkpoint is None: + return None + checkpoint = _apply_checkpoint_resume_audit( + checkpoint, + audit_by_session.get(event.session_id, {}), + ) + checkpoint = _apply_latest_checkpoint_policy( + checkpoint, + latest_by_session_run, + session_id=event.session_id, + ) + if run_id_filter and checkpoint["RunId"] != run_id_filter: + return None + if framework_filter and str(checkpoint["Framework"]).lower() != framework_filter: + return None + return checkpoint + + total = 0 + resumable_total = 0 + async for page in _iter_scoped_event_pages( + service, + session_id=session_id or None, + agent_id=request.AgentId, + user_id=request.UserId, + ): + for event in page: + checkpoint = project_checkpoint(event) + if checkpoint is None: + continue + if checkpoint.get("IsResumable") is True: + resumable_total += 1 + if request.OnlyResumable and checkpoint.get("IsResumable") is not True: + continue + total += 1 + + offset = int(request.Offset or 0) + limit = int(request.Limit) + window_end = max(total - offset, 0) + window_start = max(window_end - limit, 0) + checkpoints: list[dict[str, Any]] = [] + filtered_index = 0 + async for page in _iter_scoped_event_pages( + service, + session_id=session_id or None, + agent_id=request.AgentId, + user_id=request.UserId, + ): + for event in page: + checkpoint = project_checkpoint(event) + if checkpoint is None: + continue + if request.OnlyResumable and checkpoint.get("IsResumable") is not True: + continue + if window_start <= filtered_index < window_end: + checkpoints.append(checkpoint) + filtered_index += 1 + if filtered_index >= window_end: + break + if filtered_index >= window_end: + break + + return { + "Checkpoints": checkpoints, + "Total": total, + "ResumableTotal": resumable_total, + "HasResumableCheckpoint": resumable_total > 0, + "Offset": offset, + "Limit": limit, + } + + +@sessions_router.post("/agentengine/api/v1/ListSessionCheckpoints") +async def list_session_checkpoints_action(request: ListSessionCheckpointsActionRequest): + return _action_response("ListSessionCheckpoints", await _list_checkpoints_payload(request)) + + +@tools_router.post("/agentengine/api/v1/ListToolReceipts") +async def list_tool_receipts_action(request: ListToolReceiptsActionRequest): + service = deps.resolve_session_service() + await _require_action_session( + service, + session_id=request.SessionId, + agent_id=request.AgentId, + user_id=request.UserId, + ) + + run_id_filter = str(request.RunId or "").strip() + checkpoint_id_filter = str(request.CheckpointId or "").strip() + total = 0 + async for page in _iter_session_event_pages(service, request.SessionId): + for event in page: + receipt = _tool_receipt_event_to_action_payload(event) + if receipt is None: + continue + if run_id_filter and receipt["RunId"] != run_id_filter: + continue + if checkpoint_id_filter and receipt["CheckpointId"] != checkpoint_id_filter: + continue + total += 1 + + offset = int(request.Offset) + limit = int(request.Limit) + window_end = max(total - offset, 0) + window_start = max(window_end - limit, 0) + receipts: list[dict[str, Any]] = [] + filtered_index = 0 + async for page in _iter_session_event_pages(service, request.SessionId): + for event in page: + receipt = _tool_receipt_event_to_action_payload(event) + if receipt is None: + continue + if run_id_filter and receipt["RunId"] != run_id_filter: + continue + if checkpoint_id_filter and receipt["CheckpointId"] != checkpoint_id_filter: + continue + if window_start <= filtered_index < window_end: + receipts.append(receipt) + filtered_index += 1 + if filtered_index >= window_end: + break + if filtered_index >= window_end: + break + + return _action_response( + "ListToolReceipts", + { + "ToolReceipts": receipts, + "Total": total, + "Offset": offset, + "Limit": limit, + }, + ) diff --git a/ksadk/server/routes/streaming.py b/ksadk/server/routes/streaming.py new file mode 100644 index 00000000..be8deaf0 --- /dev/null +++ b/ksadk/server/routes/streaming.py @@ -0,0 +1,223 @@ +"""Detached streaming and per-app resource lifecycle.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator, Mapping +from typing import Any + +from fastapi import HTTPException + +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__) + +_RESERVED_UI_PATHS = {"/", "/chat", "/build", "/deploy"} +_CUSTOM_API_PROXY_ENV_KEYS = ("KSADK_USER_BACKEND_URL", "LUOLUO_USER_BACKEND_URL") +_HOP_BY_HOP_HEADERS = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + + +class _DetachedSSEStream: + _MAX_BACKLOG_CHUNKS = 256 + + def __init__( + self, + source: AsyncIterator[str], + *, + invocation_id: str | None = None, + session_id: str | None = None, + run_mode: str = "unknown", + run_trigger: str = "unknown", + ): + self._source = source + self.invocation_id = invocation_id + self.session_id = session_id + self._run_mode = run_mode + self._run_trigger = run_trigger + self._subscribers: set[asyncio.Queue[str | None]] = set() + self._backlog: list[str] = [] + self._done = False + # 后台 task 脱离请求上下文,创建时捕获 per-app registry(goal-01)。 + self._registry = get_state().stream_registry + self._task = asyncio.create_task(self._consume()) + self._registry.streams.add(self._task) + self._task.add_done_callback(self._registry.streams.discard) + if self.invocation_id: + self._registry.streams_by_invocation[self.invocation_id] = self + self._task.add_done_callback( + lambda _task: self._registry.streams_by_invocation.pop( + self.invocation_id or "", None + ) + ) + + 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(), + self.session_id, + self.invocation_id, + ) + return status in _RUN_TERMINAL_STATUSES + + async def _consume(self) -> None: + terminal_fallback_status: str | None = None + try: + async for chunk in self._source: + self._backlog.append(chunk) + if len(self._backlog) > self._MAX_BACKLOG_CHUNKS: + self._backlog = self._backlog[-self._MAX_BACKLOG_CHUNKS :] + subscribers = list(self._subscribers) + if not subscribers: + continue + await asyncio.gather( + *(subscriber.put(chunk) for subscriber in subscribers), + return_exceptions=True, + ) + except asyncio.CancelledError: + terminal_fallback_status = "cancelled" + raise + except Exception: + terminal_fallback_status = "failed" + logger.exception("Detached SSE stream failed") + raise + finally: + self._done = True + subscribers = list(self._subscribers) + if subscribers: + await asyncio.gather( + *(subscriber.put(None) for subscriber in subscribers), + return_exceptions=True, + ) + if terminal_fallback_status and self.session_id: + try: + if not await self._has_terminal_run_status(): + await deps.conversation().append_run_status_event( + session_id=self.session_id, + author="system", + status=terminal_fallback_status, + invocation_id=self.invocation_id or "", + detail=( + f"background_{terminal_fallback_status}:{self.invocation_id or ''}" + ), + session_service_provider=deps.resolve_session_service, + run_mode=self._run_mode, + run_trigger=self._run_trigger, + ) + except Exception: + logger.exception("failed to write background terminal status fallback") + + def subscribe(self) -> asyncio.Queue[str | None]: + queue: asyncio.Queue[str | None] = asyncio.Queue() + for chunk in self._backlog: + queue.put_nowait(chunk) + if self._done: + queue.put_nowait(None) + else: + self._subscribers.add(queue) + return queue + + def unsubscribe(self, queue: asyncio.Queue[str | None]) -> None: + self._subscribers.discard(queue) + + def cancel(self) -> bool: + if self._task.done(): + return False + return self._task.cancel() + + async def iter_for_client(self) -> AsyncIterator[str]: + queue = self.subscribe() + try: + while True: + # 对 queue.get() 计时而不取消上游:cancel queue.get() 只影响本订阅者的等待, + # 不影响 _consume 后台 task;心跳直接发给当前客户端,不进 _backlog, + # 因此断线重连(SubscribeRunEvents)不会回放心跳帧。 + try: + chunk = await asyncio.wait_for( + queue.get(), + timeout=deps.heartbeat_interval(), + ) + except asyncio.TimeoutError: + yield ": ping\n\n" + continue + if chunk is None: + break + yield chunk + finally: + self.unsubscribe(queue) + + +async def _cancel_detached_streams_for_session(session_id: str) -> None: + target_session_id = str(session_id or "").strip() + if not target_session_id: + return + registry = get_state().stream_registry + detached_streams = [ + detached + for detached in list(registry.streams_by_invocation.values()) + if detached.session_id == target_session_id + ] + for detached in detached_streams: + detached.cancel() + if detached_streams: + await asyncio.gather( + *(detached._task for detached in detached_streams), + return_exceptions=True, + ) + + +def _clear_detached_resume_key(registry, invocation_id: str, resume_key: tuple[str, str]) -> None: + registry.resume_keys_by_invocation.pop(invocation_id, None) + if registry.active_resume_invocation_by_key.get(resume_key) == invocation_id: + registry.active_resume_invocation_by_key.pop(resume_key, None) + + +def _detached_resume_key_from_input( + session_id: str | None, + resume_input: Mapping[str, Any] | None, +) -> tuple[str, str] | None: + if not isinstance(resume_input, Mapping): + return None + if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint": + return None + normalized_session_id = str(session_id or "").strip() + run_id = str(resume_input.get("run_id") or "").strip() + if not normalized_session_id or not run_id: + return None + return normalized_session_id, run_id + + +def _reject_if_detached_resume_active(resume_key: tuple[str, str] | None) -> None: + if resume_key is None: + return + active_resume_invocation_id = get_state().stream_registry.active_resume_invocation_by_key.get( + resume_key + ) + if not active_resume_invocation_id: + return + raise HTTPException( + status_code=409, + detail={ + "code": "resume_already_running", + "message": "A checkpoint resume is already running for this session and run.", + "invocation_id": active_resume_invocation_id, + "session_id": resume_key[0], + "run_id": resume_key[1], + }, + ) diff --git a/ksadk/server/routes/workspace.py b/ksadk/server/routes/workspace.py new file mode 100644 index 00000000..2ad84ad0 --- /dev/null +++ b/ksadk/server/routes/workspace.py @@ -0,0 +1,381 @@ +"""Workspace, attachment upload, model catalog, and cancel routes.""" + +from __future__ import annotations + +import io +import logging +import uuid +import zipfile +from pathlib import PurePosixPath +from typing import Any, Optional +from urllib.parse import quote + +from fastapi import File, Form, HTTPException, Query, Request, UploadFile +from fastapi.responses import Response, StreamingResponse +from pydantic import BaseModel + +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_common.workspace_files.preview import ( + build_workspace_file_base_href, + build_workspace_preview_csp, + inject_workspace_html_preview, +) + +from . import dependencies as deps +from .common import ( + _action_response, + _resolve_active_runner, + _resolve_current_model, + _workspace_root_dir, + _workspace_runtime_request, +) +from .models import ( + CancelRunActionRequest, + WorkspaceDeleteActionRequest, + WorkspaceListActionRequest, +) +from .projection import ( + _agent_contains_invocation, + _require_action_session, + _session_contains_invocation, +) +from .routers import control_router, workspace_router + +logger = logging.getLogger(__name__) + + +@workspace_router.post("/agentengine/api/v1/UploadFile") +async def upload_file_action(file: UploadFile = File(...)): + file_id = uuid.uuid4().hex + data = await file.read() + file_uri, _local_path = await AttachmentStorageService().store( + data=data, + file_id=file_id, + display_name=file.filename, + mime_type=file.content_type, + ) + + return _action_response( + "UploadFile", + { + "FileData": { + "fileUri": file_uri, + "displayName": file.filename or "uploaded_file", + "mimeType": file.content_type or "application/octet-stream", + "sizeBytes": len(data), + } + }, + ) + + +@workspace_router.get("/agentengine/api/v1/AttachmentContent", include_in_schema=False) +async def attachment_content_action(FileUri: str = Query(...)): + loaded = AttachmentStorageService().read(FileUri) + if loaded is None: + raise HTTPException(status_code=404, detail="Attachment not found") + + return Response( + content=loaded.data, + media_type=loaded.mime_type or "application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{loaded.display_name}"'}, + ) + + +@workspace_router.post("/agentengine/api/v1/ListWorkspaceFiles") +async def list_workspace_files_action(request: WorkspaceListActionRequest): + response = await _workspace_runtime_request( + "GET", + "/_ksadk/workspace/v1/entries", + params={ + "path": request.Path, + "recursive": "true" if request.Recursive else "false", + }, + ) + return _action_response("ListWorkspaceFiles", response.json()) + + +@workspace_router.post("/agentengine/api/v1/AddWorkspaceFile") +async def upload_workspace_file_action( + file: UploadFile = File(...), + AgentId: Optional[str] = Form(None), + Path: str = Form(...), +): + del AgentId + try: + payload = await file.read() + finally: + await file.close() + + file_name = file.filename or Path.rsplit("/", 1)[-1] + response = await _workspace_runtime_request( + "POST", + f"/_ksadk/workspace/v1/files/{quote(Path, safe='/')}", + files={ + "file": ( + file_name, + payload, + file.content_type or "application/octet-stream", + ) + }, + ) + return _action_response("AddWorkspaceFile", response.json()) + + +@workspace_router.post("/agentengine/api/v1/DeleteWorkspaceFile") +async def delete_workspace_file_action(request: WorkspaceDeleteActionRequest): + response = await _workspace_runtime_request( + "DELETE", + f"/_ksadk/workspace/v1/files/{quote(request.Path, safe='/')}", + ) + return _action_response("DeleteWorkspaceFile", response.json()) + + +@control_router.post("/agentengine/api/v1/CancelRun") +async def cancel_run_action(request: CancelRunActionRequest): + detached = get_state().stream_registry.streams_by_invocation.get(request.InvocationId) + service = deps.resolve_session_service() + scoped_session_id = str(request.SessionId or "").strip() + detached_session_id = str(detached.session_id or "").strip() if detached is not None else "" + if scoped_session_id and detached_session_id and scoped_session_id != detached_session_id: + raise HTTPException( + status_code=409, + detail="InvocationId does not belong to SessionId", + ) + if not scoped_session_id and detached is not None: + scoped_session_id = detached_session_id + if scoped_session_id: + await _require_action_session( + service, + session_id=scoped_session_id, + agent_id=request.AgentId, + user_id=request.UserId, + ) + if detached is None and not await _session_contains_invocation( + service, + scoped_session_id, + request.InvocationId, + ): + raise HTTPException( + status_code=409, + detail="InvocationId does not belong to SessionId", + ) + elif request.UserId: + if not request.AgentId: + raise HTTPException(status_code=400, detail="AgentId is required with UserId") + if not await _agent_contains_invocation( + service, + request.AgentId, + request.InvocationId, + user_id=request.UserId, + ): + raise HTTPException(status_code=404, detail="Invocation not found") + found = detached 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: + 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" + 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 + return _action_response( + "CancelRun", + { + "Cancelled": bool(cancel_requested or runner_accepted), + "Found": found, + "Status": status, + "RunnerCancelStatus": runner_cancel_status, + }, + ) + + +@workspace_router.get("/agentengine/api/v1/GetWorkspaceFileContent", include_in_schema=False) +async def get_workspace_file_content_action( + FilePath: str = Query(...), + AgentId: Optional[str] = Query(None), +): + del AgentId + response = await _workspace_runtime_request( + "GET", + f"/_ksadk/workspace/v1/files/{quote(FilePath, safe='/')}", + ) + headers = {} + for key in ("content-disposition", "last-modified"): + value = response.headers.get(key) + if value: + headers[key] = value + return Response( + content=response.content, + status_code=response.status_code, + headers=headers, + media_type=response.headers.get("content-type"), + ) + + +@workspace_router.get("/agentengine/api/v1/ws/{agent_id}/{file_path:path}", include_in_schema=False) +async def workspace_file_path_route(request: Request, agent_id: str, file_path: str): + response = await _workspace_runtime_request( + "GET", + f"/_ksadk/workspace/v1/files/{quote(file_path, safe='/')}", + ) + headers = {} + for key in ("content-disposition", "last-modified"): + value = response.headers.get(key) + if value: + headers[key] = value + + content_type = response.headers.get("content-type", "") + is_html = "text/html" in content_type or file_path.lower().endswith((".html", ".htm")) + + if is_html and response.status_code == 200: + del agent_id + base_href = build_workspace_file_base_href(file_path) + asset_source = f"{request.url.scheme}://{request.url.netloc}{base_href}" + html_doc = response.content.decode("utf-8", errors="replace") + html_doc = inject_workspace_html_preview(html_doc, file_path) + headers.pop("content-disposition", None) + headers["Content-Security-Policy"] = build_workspace_preview_csp(asset_source) + return Response( + content=html_doc.encode("utf-8"), + status_code=response.status_code, + headers=headers, + media_type="text/html; charset=utf-8", + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=headers, + media_type=content_type, + ) + + +@workspace_router.get("/agentengine/api/v1/ExportWorkspaceZip", include_in_schema=False) +async def export_workspace_zip( + AgentId: Optional[str] = Query(None), + Path: str = Query("."), +): + del AgentId + dir_path = Path.strip() or "." + response = await _workspace_runtime_request( + "GET", + "/_ksadk/workspace/v1/entries", + params={"path": dir_path, "recursive": "true"}, + ) + data = response.json() if response.status_code == 200 else {} + entries = data.get("Entries", []) if isinstance(data, dict) else [] + root = _workspace_root_dir() + root_resolved = root.resolve() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for entry in entries: + if entry.get("Type") != "file": + continue + rel = entry.get("Path", "") + if not rel: + continue + rel_path = PurePosixPath(rel) + if rel_path.is_absolute() or ".." in rel_path.parts: + continue + target = root.joinpath(*rel_path.parts) + if target.is_symlink(): + continue + try: + resolved_target = target.resolve(strict=True) + except OSError: + continue + if not resolved_target.is_relative_to(root_resolved): + continue + if resolved_target.is_file(): + zf.writestr(rel_path.as_posix(), resolved_target.read_bytes()) + buf.seek(0) + zip_name = f"workspace-{dir_path.replace('/', '-')}.zip" if dir_path != "." else "workspace.zip" + return StreamingResponse( + buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, + ) + + +def _normalize_model_catalog_items(raw_models: list[Any]) -> list[dict[str, Any]]: + """统一模型目录 shape,并按 id 去重。 + + 这里刻意保留上游原始 dict 字段,再补 canonical metadata。 + 这样两周后模型服务扩展字段时,这一层不会再次把信息裁掉。 + """ + + normalized_by_id: dict[str, dict[str, Any]] = {} + for raw_model in raw_models: + item = normalize_model_metadata(raw_model) + normalized_by_id[item["id"]] = item + return sorted(normalized_by_id.values(), key=lambda item: item["id"]) + + +async def _build_models_payload() -> dict[str, Any]: + import os + + import httpx + + api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE") + api_key = os.getenv("OPENAI_API_KEY") + current_model, source = _resolve_current_model() + + def _fallback_catalog() -> dict[str, Any]: + models = _normalize_model_catalog_items([current_model]) if current_model else [] + return { + "data": models, + "current": current_model, + "source": source, + } + + if not api_base: + return _fallback_catalog() + + try: + base_url = api_base.rstrip("/") + if base_url.endswith("/v1"): + url = f"{base_url}/models" + else: + url = f"{base_url}/v1/models" + + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async with httpx.AsyncClient(verify=False, timeout=10) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + data = resp.json() + + if isinstance(data, list): + models = _normalize_model_catalog_items(list(data)) + else: + models = _normalize_model_catalog_items(list(data.get("data", []))) + if current_model and all( + 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} + except Exception as e: + logger.error(f"Failed to fetch models: {e}") + fallback = _fallback_catalog() + fallback["error"] = str(e) + return fallback + + +class ListAgentModelsRequest(BaseModel): + AgentId: Optional[str] = None + Name: Optional[str] = None diff --git a/ksadk/server/terminal_sessions.py b/ksadk/server/terminal_sessions.py index e4e93844..f5c42d86 100644 --- a/ksadk/server/terminal_sessions.py +++ b/ksadk/server/terminal_sessions.py @@ -4,7 +4,9 @@ import asyncio import contextlib +import importlib import json +import logging import os import shutil import signal @@ -12,7 +14,7 @@ import uuid from dataclasses import dataclass, field from pathlib import Path, PurePosixPath -from typing import Any, Callable +from typing import Any, Callable, ContextManager from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse @@ -28,20 +30,19 @@ validate_terminal_exec_argv as validate_exec_argv_with_policy, ) -try: - import pty -except ImportError: # pragma: no cover - exercised through subprocess import test - pty = None # type: ignore[assignment] +logger = logging.getLogger(__name__) -try: - import select -except ImportError: # pragma: no cover - Windows compatibility - select = None # type: ignore[assignment] -try: - import termios -except ImportError: # pragma: no cover - exercised through subprocess import test - termios = None # type: ignore[assignment] +def _optional_platform_module(name: str) -> Any | None: + try: + return importlib.import_module(name) + except ImportError: + return None + + +pty = _optional_platform_module("pty") +select = _optional_platform_module("select") +termios = _optional_platform_module("termios") TERMINAL_REPLAY_BUFFER_BYTES = 64 * 1024 @@ -96,12 +97,7 @@ def _env_bool(name: str, default: bool) -> bool: def native_terminal_supported() -> bool: - return ( - os.name != "nt" - and pty is not None - and select is not None - and termios is not None - ) + return os.name != "nt" and pty is not None and select is not None and termios is not None class TerminalSessionManager: @@ -121,6 +117,24 @@ def reset_for_tests(self) -> None: self._terminate_session(session) self.sessions.clear() + async def close(self) -> None: + """Terminate and await every terminal task owned by this manager.""" + sessions = list(self.sessions.values()) + tasks: list[asyncio.Task[Any]] = [] + attachments: set[WebSocket] = set() + for session in sessions: + self._terminate_session(session) + attachments.update(session.attachments) + tasks.extend( + task for task in (session.reader_task, session.wait_task) if task is not None + ) + for ws in attachments: + with contextlib.suppress(Exception): + await ws.close(code=1012, reason="runtime shutting down") + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + self.sessions.clear() + def serialize(self, session: TerminalSession) -> dict[str, Any]: return { "terminal_session_id": session.id, @@ -213,7 +227,10 @@ async def legacy_start(self, ws: WebSocket, payload: dict[str, Any]) -> None: session = TerminalSession( id=f"term-{uuid.uuid4().hex[:12]}", session_id=str( - payload.get("session_id") or payload.get("sessionId") or payload.get("SessionId") or "" + payload.get("session_id") + or payload.get("sessionId") + or payload.get("SessionId") + or "" ).strip(), mode=str(payload.get("mode") or "tui").strip().lower(), framework=self._current_framework(), @@ -255,7 +272,10 @@ def _cleanup_expired_locked(self) -> None: for session in list(self.sessions.values()): if session.deleted: continue - if session.status == "detached" and now - session.updated_at > TERMINAL_DETACHED_TTL_SECONDS: + if ( + session.status == "detached" + and now - session.updated_at > TERMINAL_DETACHED_TTL_SECONDS + ): session.deleted = True session.status = "deleted" self._terminate_session(session) @@ -272,7 +292,9 @@ def _enforce_limits_locked(self, *, session_id: str) -> None: ): raise ValueError("too many terminal sessions in this runtime") if session_id: - per_business_session = [session for session in active_sessions if session.session_id == session_id] + per_business_session = [ + session for session in active_sessions if session.session_id == session_id + ] if len(per_business_session) >= _env_int( "AGENTENGINE_TERMINAL_MAX_SESSIONS_PER_BUSINESS_SESSION", MAX_TERMINAL_SESSIONS_PER_BUSINESS_SESSION, @@ -285,7 +307,11 @@ def _resolve_terminal_command(self, session: TerminalSession) -> list[str]: if mode == "tui": return self._resolve_tui_command(session) if mode == "exec": - policy = HERMES_TERMINAL_EXEC_POLICY if framework == "hermes" else OPENCLAW_TERMINAL_EXEC_POLICY + policy = ( + HERMES_TERMINAL_EXEC_POLICY + if framework == "hermes" + else OPENCLAW_TERMINAL_EXEC_POLICY + ) if framework not in {"hermes", "openclaw"}: policy = GENERIC_TERMINAL_EXEC_POLICY return validate_exec_argv_with_policy(session.argv, policy=policy) @@ -296,13 +322,17 @@ def _resolve_tui_command(self, session: TerminalSession) -> list[str]: if framework == "hermes" and shutil.which("hermes"): command = ["hermes", "chat"] if session.session_id and _env_bool("HERMES_TERMINAL_RESUME_ENABLED", True): - command.extend([os.getenv("HERMES_TERMINAL_RESUME_FLAG", "--resume"), session.session_id]) + command.extend( + [os.getenv("HERMES_TERMINAL_RESUME_FLAG", "--resume"), session.session_id] + ) session.argv = command return command if framework == "openclaw" and shutil.which("openclaw"): command = ["openclaw", "tui"] if session.session_id: - command.extend([os.getenv("OPENCLAW_TERMINAL_SESSION_FLAG", "--session"), session.session_id]) + command.extend( + [os.getenv("OPENCLAW_TERMINAL_SESSION_FLAG", "--session"), session.session_id] + ) session.argv = command return command shell = os.getenv("SHELL") or "/bin/sh" @@ -367,13 +397,15 @@ def _terminate_session(self, session: TerminalSession) -> None: session.fd = None async def _session_reader(self, session: TerminalSession) -> None: - if session.fd is None: + fd = session.fd + selector = select + if fd is None or selector is None: return loop = asyncio.get_running_loop() while True: - await loop.run_in_executor(None, lambda: select.select([session.fd], [], [], None)) + await loop.run_in_executor(None, lambda: selector.select([fd], [], [], None)) try: - data = os.read(session.fd, 4096) + data = os.read(fd, 4096) except OSError: return if not data: @@ -396,7 +428,11 @@ async def _session_waiter(self, session: TerminalSession) -> None: async def _attach_existing(self, ws: WebSocket, session: TerminalSession) -> None: session.attachments.add(ws) - session.status = "running" if session.status == "detached" and session.pid is not None else session.status + session.status = ( + "running" + if session.status == "detached" and session.pid is not None + else session.status + ) session.updated_at = time.time() try: await ws.send_text( @@ -466,9 +502,10 @@ async def _broadcast_control(self, session: TerminalSession, payload: dict[str, def _persist_metadata(self, session: TerminalSession) -> None: try: - state_dir = Path( - os.getenv("AGENTENGINE_TERMINAL_STATE_DIR", "/home/node/.agentengine/terminal") - ) + # Persist only under the current runtime user's state directory. An + # environment-controlled absolute path would select an arbitrary + # write location before the terminal server starts. + state_dir = Path.home() / ".agentengine" / "terminal" state_dir.mkdir(parents=True, exist_ok=True) (state_dir / f"{session.id}.json").write_text( json.dumps(self.serialize(session), ensure_ascii=False, indent=2), @@ -482,7 +519,8 @@ def _set_winsize(fd: int, rows: int, cols: int) -> None: if termios is None: return with contextlib.suppress(Exception): - termios.tcsetwinsize(fd, (int(rows or 24), int(cols or 80))) + set_winsize = getattr(termios, "tcsetwinsize") + set_winsize(fd, (int(rows or 24), int(cols or 80))) async def _wait_process(pid: int) -> int: @@ -495,14 +533,19 @@ async def _wait_process(pid: int) -> int: return status -def register_terminal_routes(app: FastAPI, manager: TerminalSessionManager) -> None: +def register_terminal_routes( + app: FastAPI, + manager: TerminalSessionManager, + *, + bind_context: Callable[[], ContextManager[Any]] | None = None, +) -> None: @app.post("/_ksadk/terminal/sessions") async def create_terminal_session(request: Request) -> JSONResponse: payload = await request.json() try: session = await manager.create_or_reuse(payload if isinstance(payload, dict) else {}) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) + except ValueError: + return JSONResponse({"error": "invalid_terminal_request"}, status_code=400) return JSONResponse({"session": manager.serialize(session)}) @app.get("/_ksadk/terminal/sessions") @@ -525,27 +568,37 @@ async def delete_terminal_session(terminal_session_id: str) -> JSONResponse: @app.websocket("/_ksadk/terminal/ws") async def terminal_ws(ws: WebSocket) -> None: - if TERMINAL_SUBPROTOCOL not in (ws.headers.get("sec-websocket-protocol") or ""): - await ws.close(code=4400, reason="missing ks-terminal.v1 subprotocol") - return - await ws.accept(subprotocol=TERMINAL_SUBPROTOCOL) - try: - query_session_id = str(ws.query_params.get("terminal_session_id") or "").strip() - if query_session_id: - await manager.attach(ws, query_session_id) + context = bind_context() if bind_context is not None else contextlib.nullcontext() + with context: + if TERMINAL_SUBPROTOCOL not in (ws.headers.get("sec-websocket-protocol") or ""): + await ws.close(code=4400, reason="missing ks-terminal.v1 subprotocol") return - first = await ws.receive_text() - payload = json.loads(first) - if payload.get("type") == "attach": - terminal_session_id = str(payload.get("terminal_session_id") or "").strip() - await manager.attach(ws, terminal_session_id) + await ws.accept(subprotocol=TERMINAL_SUBPROTOCOL) + try: + query_session_id = str(ws.query_params.get("terminal_session_id") or "").strip() + if query_session_id: + await manager.attach(ws, query_session_id) + return + first = await ws.receive_text() + payload = json.loads(first) + if payload.get("type") == "attach": + terminal_session_id = str(payload.get("terminal_session_id") or "").strip() + await manager.attach(ws, terminal_session_id) + return + if payload.get("type") != "start": + raise ValueError("first frame must be start") + await manager.legacy_start(ws, payload) + except WebSocketDisconnect: return - if payload.get("type") != "start": - raise ValueError("first frame must be start") - await manager.legacy_start(ws, payload) - except WebSocketDisconnect: - return - except Exception as exc: - if ws.client_state == WebSocketState.CONNECTED: - await ws.send_text(json.dumps({"type": "error", "message": str(exc)})) - await ws.close() + except Exception: + logger.exception("terminal websocket request failed") + if ws.client_state == WebSocketState.CONNECTED: + await ws.send_text( + json.dumps( + { + "type": "error", + "message": "Terminal request failed; see server logs for details.", + } + ) + ) + await ws.close() diff --git a/ksadk/sessions/__init__.py b/ksadk/sessions/__init__.py index 8aeffe06..7ba494cf 100644 --- a/ksadk/sessions/__init__.py +++ b/ksadk/sessions/__init__.py @@ -1,9 +1,12 @@ from __future__ import annotations +import asyncio +import contextvars import logging import os +from contextlib import contextmanager from dataclasses import dataclass -from typing import Callable +from typing import Callable, Iterator from urllib.parse import urlsplit, urlunsplit from ksadk.sessions.base import BaseSessionService, Session, SessionEvent, SessionState @@ -21,6 +24,10 @@ from ksadk.sessions.local_service import create_local_session_service _cached_session_service: BaseSessionService | None = None +_cached_session_service_loop: asyncio.AbstractEventLoop | None = None +_session_service_override: contextvars.ContextVar[BaseSessionService | None] = ( + contextvars.ContextVar("ksadk_session_service", default=None) +) logger = logging.getLogger(__name__) @@ -48,12 +55,16 @@ def register_session_backend(name: str, factory: SessionBackendFactory) -> None: def resolve_session_backend_config(*, backend: str | None = None) -> SessionBackendConfig: _register_builtin_backends() resolved_backend = ( - backend - or os.getenv("KSADK_SESSION_BACKEND") - or os.getenv("AGENTENGINE_SESSION_BACKEND") - or os.getenv("KSADK_STM_BACKEND") - or "" - ).strip().lower() + ( + backend + or os.getenv("KSADK_SESSION_BACKEND") + or os.getenv("AGENTENGINE_SESSION_BACKEND") + or os.getenv("KSADK_STM_BACKEND") + or "" + ) + .strip() + .lower() + ) if not resolved_backend: resolved_backend = "local" if resolved_backend == "sqlite": @@ -86,14 +97,10 @@ def resolve_session_backend_config(*, backend: str | None = None) -> SessionBack or "default" ).strip() tenant_id = ( - os.getenv("KSADK_TENANT_ID") - or os.getenv("AGENTENGINE_TENANT_ID") - or "default" + os.getenv("KSADK_TENANT_ID") or os.getenv("AGENTENGINE_TENANT_ID") or "default" ).strip() workspace_id = ( - os.getenv("KSADK_WORKSPACE_ID") - or os.getenv("AGENTENGINE_WORKSPACE_ID") - or "default" + os.getenv("KSADK_WORKSPACE_ID") or os.getenv("AGENTENGINE_WORKSPACE_ID") or "default" ).strip() return SessionBackendConfig( backend=resolved_backend, @@ -188,7 +195,8 @@ def log_session_backend_diagnostics(*, backend: str | None = None) -> None: logger.info("KSADK session backend: %s", payload) if not bool(payload.get("ProductionSafe")): logger.warning( - "KSADK session backend %s is not cross-pod recoverable; use postgres for K8s multi-replica deployments.", + "KSADK session backend %s is not cross-pod recoverable; " + "use postgres for K8s multi-replica deployments.", payload.get("Backend"), ) @@ -225,23 +233,64 @@ def create_session_service( return service +@contextmanager +def bind_session_service(service: BaseSessionService) -> Iterator[None]: + """Bind a service to the current request/task context.""" + token = _session_service_override.set(service) + try: + yield + finally: + _session_service_override.reset(token) + + +def _running_loop() -> asyncio.AbstractEventLoop | None: + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + def resolve_session_service() -> BaseSessionService: - global _cached_session_service + """Resolve a request-bound service or a loop-safe legacy fallback. + + ``LocalSessionService`` owns asyncio synchronization primitives. A process + cache shared by pytest-asyncio loops can therefore reuse a lock bound to a + closed peer loop. Legacy non-request callers retain caching, while async + callers never reuse a cache created by another running loop. + """ + global _cached_session_service, _cached_session_service_loop + + override = _session_service_override.get() + if override is not None: + return override + + loop = _running_loop() if _cached_session_service is not None: - return _cached_session_service + if loop is None or _cached_session_service_loop is loop: + return _cached_session_service + if _cached_session_service_loop is None: + # A synchronous startup path created the service. Associate it + # with the first async caller rather than replacing it eagerly. + _cached_session_service_loop = loop + return _cached_session_service + _cached_session_service = create_session_service() + _cached_session_service_loop = loop return _cached_session_service async def reset_session_service() -> None: - global _cached_session_service + global _cached_session_service, _cached_session_service_loop if _cached_session_service is None: + _cached_session_service_loop = None return - close = getattr(_cached_session_service, "aclose", None) + service = _cached_session_service + _cached_session_service = None + _cached_session_service_loop = None + close = getattr(service, "aclose", None) if close is not None: await close() - _cached_session_service = None def get_session_service() -> BaseSessionService: @@ -255,6 +304,7 @@ async def close_session_service() -> None: __all__ = [ "ADKSessionAdapter", "BaseSessionService", + "bind_session_service", "ConversationSessionCore", "InMemorySessionService", "LangChainSessionAdapter", diff --git a/ksadk/sessions/base.py b/ksadk/sessions/base.py index 3b77af97..450016f9 100644 --- a/ksadk/sessions/base.py +++ b/ksadk/sessions/base.py @@ -78,16 +78,11 @@ def from_dict( return cls( id=str(payload.get("id") or generate_id()), session_id=str( - payload.get("session_id") - or payload.get("sessionId") - or session_id - or "" + payload.get("session_id") or payload.get("sessionId") or session_id or "" ), author=str(payload.get("author") or ""), event_type=str( - payload.get("event_type") - or payload.get("eventType") - or _infer_event_type(payload) + payload.get("event_type") or payload.get("eventType") or _infer_event_type(payload) ), content=dict(payload.get("content") or {}), timestamp=normalize_timestamp(payload.get("timestamp")), @@ -190,10 +185,7 @@ def from_dict(cls, payload: dict[str, Any]) -> "Session": return cls( id=str(payload.get("id") or generate_id()), agent_id=str( - payload.get("agent_id") - or payload.get("app_name") - or payload.get("appName") - or "" + payload.get("agent_id") or payload.get("app_name") or payload.get("appName") or "" ), user_id=str(payload.get("user_id") or payload.get("userId") or ""), title=str(payload.get("title") or payload.get("Title") or ""), @@ -272,6 +264,14 @@ async def create_session( async def get_session(self, session_id: str) -> Optional[Session]: raise NotImplementedError + async def get_session_metadata(self, session_id: str) -> Optional[Session]: + """Return session ownership/state without requiring the event transcript.""" + + session = await self.get_session(session_id) + if session is not None: + session.events = [] + return session + @abc.abstractmethod async def list_sessions( self, @@ -331,6 +331,26 @@ async def count_events( ) -> int: raise NotImplementedError + @abc.abstractmethod + async def get_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[SessionEvent]: + """跨会话事件查询,必须由存储后端提供无截断实现。""" + raise NotImplementedError + + @abc.abstractmethod + async def count_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + """跨会话事件计数,必须由存储后端提供无截断实现。""" + raise NotImplementedError + @abc.abstractmethod async def get_state( self, diff --git a/ksadk/sessions/continuity.py b/ksadk/sessions/continuity.py index ea8c4562..1788d870 100644 --- a/ksadk/sessions/continuity.py +++ b/ksadk/sessions/continuity.py @@ -22,7 +22,7 @@ class SessionContinuityStatus: details: dict[str, Any] = field(default_factory=dict) def to_payload(self) -> dict[str, Any]: - payload = { + payload: dict[str, Any] = { "Level": self.level.value, "Path": self.path, "Runner": self.runner, @@ -320,8 +320,7 @@ def continuity_status( stm_backend = getattr(_stm, "backend", None) if _stm is not None else None is_durable = stm_backend is not None and stm_backend != "local" level = ( - SessionContinuityLevel.RUNTIME if is_durable - else SessionContinuityLevel.SEMANTIC + SessionContinuityLevel.RUNTIME if is_durable else SessionContinuityLevel.SEMANTIC ) path = "adk_resume" elif has_native_session: diff --git a/ksadk/sessions/in_memory.py b/ksadk/sessions/in_memory.py index b2f68bc8..290df7cf 100644 --- a/ksadk/sessions/in_memory.py +++ b/ksadk/sessions/in_memory.py @@ -5,6 +5,7 @@ import time from typing import Optional +from ksadk.ids import new_session_id from ksadk.sessions.base import BaseSessionService, Session, SessionEvent, SessionState, generate_id @@ -25,7 +26,7 @@ async def create_session( return copy.deepcopy(self._sessions[session_id]) session = Session( - id=session_id or generate_id(), + id=session_id or new_session_id(), agent_id=agent_id, user_id=user_id, ) @@ -43,6 +44,15 @@ async def get_session(self, session_id: str) -> Optional[Session]: session = self._sessions.get(session_id) return copy.deepcopy(session) if session else None + async def get_session_metadata(self, session_id: str) -> Optional[Session]: + async with self._lock: + session = self._sessions.get(session_id) + if session is None: + return None + metadata = copy.deepcopy(session) + metadata.events = [] + return metadata + async def list_sessions( self, agent_id: str, @@ -56,7 +66,10 @@ async def list_sessions( for session in self._sessions.values() if session.agent_id == agent_id and (user_id is None or session.user_id == user_id) ] - sessions.sort(key=lambda item: (item.updated_at, item.created_at), reverse=True) + sessions.sort( + key=lambda item: (item.updated_at, item.created_at, item.id), + reverse=True, + ) start = offset or 0 end = None if limit is None else start + limit return sessions[start:end] @@ -191,6 +204,37 @@ async def count_events( events = [event for event in events if event.seq_id < before_seq_id] return len(events) + async def get_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[SessionEvent]: + async with self._lock: + merged = [ + copy.deepcopy(event) + for session in self._sessions.values() + if session.agent_id == agent_id and (user_id is None or session.user_id == user_id) + for event in session.events + ] + merged.sort(key=lambda event: (event.timestamp, event.seq_id, event.id)) + end = max(len(merged) - (offset or 0), 0) + start = 0 if limit is None else max(end - limit, 0) + return merged[start:end] + + async def count_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + async with self._lock: + return sum( + len(session.events) + for session in self._sessions.values() + if session.agent_id == agent_id and (user_id is None or session.user_id == user_id) + ) + async def get_state( self, agent_id: str, diff --git a/ksadk/sessions/local_service.py b/ksadk/sessions/local_service.py index ecd35147..cd93a35f 100644 --- a/ksadk/sessions/local_service.py +++ b/ksadk/sessions/local_service.py @@ -7,11 +7,12 @@ import os import sqlite3 import time +from collections.abc import Iterator from contextlib import closing, contextmanager from pathlib import Path -from collections.abc import Iterator from typing import Optional +from ksadk.ids import new_session_id from ksadk.sessions.base import ( BaseSessionService, Session, @@ -76,6 +77,14 @@ async def get_session(self, session_id: str) -> Optional[Session]: async with self._lock: return await asyncio.to_thread(self._get_session_sync, session_id) + async def get_session_metadata(self, session_id: str) -> Optional[Session]: + async with self._lock: + return await asyncio.to_thread( + self._get_session_sync, + session_id, + include_events=False, + ) + async def list_sessions( self, agent_id: str, @@ -161,6 +170,114 @@ async def count_events( before_seq_id, ) + async def get_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[SessionEvent]: + async with self._lock: + return await asyncio.to_thread( + self._get_events_for_agent_sync, + agent_id, + user_id, + offset, + limit, + ) + + async def count_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + async with self._lock: + return await asyncio.to_thread( + self._count_events_for_agent_sync, + agent_id, + user_id, + ) + + def _get_events_for_agent_sync( + self, + agent_id: str, + user_id: Optional[str], + offset: Optional[int], + limit: Optional[int], + ) -> list[SessionEvent]: + columns = ( + "e.id, e.session_id, e.author, e.event_type, e.content_json, e.timestamp, " + "e.state_delta_json, e.seq_id, e.invocation_id, e.metadata_json" + ) + user_clause = "AND s.user_id = ?" if user_id is not None else "" + from_clause = ( + f"FROM {KSADK_EVENTS_TABLE} e " + f"JOIN {KSADK_SESSIONS_TABLE} s ON s.id = e.session_id " + f"WHERE s.agent_id = ? {user_clause}" + ) + base_params: list[object] = [agent_id] + if user_id is not None: + base_params.append(user_id) + with self._connection() as connection: + if limit is not None or offset is not None: + # 与 get_events 一致的"最新 N 条"尾部语义:先 DESC 取窗口再 ASC 重排 + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( + SELECT {columns} + {from_clause} + ORDER BY e.timestamp DESC, e.seq_id DESC, e.id DESC + LIMIT ? OFFSET ? + ) + ORDER BY timestamp ASC, seq_id ASC, id ASC + """ + params = [*base_params, limit if limit is not None else -1, offset or 0] + else: + query = f""" + SELECT {columns} + {from_clause} + ORDER BY e.timestamp ASC, e.seq_id ASC, e.id ASC + """ + params = base_params + rows = connection.execute(query, params).fetchall() + return [ + SessionEvent( + id=row["id"], + session_id=row["session_id"], + author=row["author"], + event_type=row["event_type"], + content=json.loads(row["content_json"] or "{}"), + timestamp=row["timestamp"], + state_delta=json.loads(row["state_delta_json"] or "{}"), + seq_id=row["seq_id"], + invocation_id=row["invocation_id"], + metadata=json.loads(row["metadata_json"] or "{}"), + ) + for row in rows + ] + + def _count_events_for_agent_sync( + self, + agent_id: str, + user_id: Optional[str], + ) -> int: + user_clause = "AND s.user_id = ?" if user_id is not None else "" + params: list[object] = [agent_id] + if user_id is not None: + params.append(user_id) + with self._connection() as connection: + row = connection.execute( + f""" + SELECT COUNT(*) AS total + FROM {KSADK_EVENTS_TABLE} e + JOIN {KSADK_SESSIONS_TABLE} s ON s.id = e.session_id + WHERE s.agent_id = ? {user_clause} + """, + params, + ).fetchone() + return int(row["total"] if row else 0) + async def get_state( self, agent_id: str, @@ -258,9 +375,7 @@ def _migrate_legacy_schema(self, connection: sqlite3.Connection) -> None: and not self._table_exists(connection, KSADK_EVENTS_TABLE) and {"session_id", "author", "event_type"}.issubset(legacy_event_columns) ): - connection.execute( - f"ALTER TABLE {LEGACY_EVENTS_TABLE} RENAME TO {KSADK_EVENTS_TABLE}" - ) + connection.execute(f"ALTER TABLE {LEGACY_EVENTS_TABLE} RENAME TO {KSADK_EVENTS_TABLE}") legacy_state_columns = self._table_columns(connection, LEGACY_STATES_TABLE) if ( @@ -268,15 +383,12 @@ def _migrate_legacy_schema(self, connection: sqlite3.Connection) -> None: and not self._table_exists(connection, KSADK_STATES_TABLE) and {"scope", "agent_id", "state_json"}.issubset(legacy_state_columns) ): - connection.execute( - f"ALTER TABLE {LEGACY_STATES_TABLE} RENAME TO {KSADK_STATES_TABLE}" - ) + connection.execute(f"ALTER TABLE {LEGACY_STATES_TABLE} RENAME TO {KSADK_STATES_TABLE}") def _ensure_schema(self) -> None: with self._connection() as connection: self._migrate_legacy_schema(connection) - connection.executescript( - f""" + connection.executescript(f""" CREATE TABLE IF NOT EXISTS {KSADK_SESSIONS_TABLE} ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, @@ -309,6 +421,16 @@ def _ensure_schema(self) -> None: CREATE INDEX IF NOT EXISTS idx_ksadk_events_session_seq ON {KSADK_EVENTS_TABLE} (session_id, seq_id); + -- 跨会话事件查询(get_events_for_agent)JOIN sessions 按 + -- s.agent_id 过滤 + ORDER BY e.timestamp;覆盖索引服务 JOIN 键 + -- s.id=e.session_id + 排序。 + CREATE INDEX IF NOT EXISTS idx_ksadk_events_session_ts + ON {KSADK_EVENTS_TABLE} (session_id, timestamp, id); + + -- ListSessions 按 agent_id 过滤 + updated_at DESC 排序。 + CREATE INDEX IF NOT EXISTS idx_ksadk_sessions_agent_updated + ON {KSADK_SESSIONS_TABLE} (agent_id, updated_at DESC, id); + CREATE TABLE IF NOT EXISTS {KSADK_STATES_TABLE} ( scope TEXT NOT NULL, agent_id TEXT NOT NULL, @@ -319,8 +441,7 @@ def _ensure_schema(self) -> None: updated_at REAL NOT NULL, PRIMARY KEY (scope, agent_id, user_id, session_id) ); - """ - ) + """) self._ensure_columns( connection, KSADK_SESSIONS_TABLE, @@ -367,7 +488,7 @@ def _create_session_sync( now = time.time() session = Session( - id=session_id or generate_id(), + id=session_id or new_session_id(), agent_id=agent_id, user_id=user_id, created_at=now, @@ -413,6 +534,7 @@ def _get_session_sync( session_id: str, *, connection: Optional[sqlite3.Connection] = None, + include_events: bool = True, ) -> Optional[Session]: owns_connection = connection is None connection = connection or self._connect() @@ -439,7 +561,11 @@ def _get_session_sync( first_prompt=row["first_prompt"], last_prompt=row["last_prompt"], state=json.loads(row["state_json"] or "{}"), - events=self._get_events_sync(session_id, connection=connection), + events=( + self._get_events_sync(session_id, connection=connection) + if include_events + else [] + ), created_at=row["created_at"], updated_at=row["updated_at"], version=row["version"], @@ -467,7 +593,7 @@ def _list_sessions_sync( if user_id is not None: query += " AND user_id = ?" params.append(user_id) - query += " ORDER BY updated_at DESC, created_at DESC" + query += " ORDER BY updated_at DESC, created_at DESC, id DESC" if limit is not None: query += " LIMIT ?" params.append(limit) @@ -520,8 +646,12 @@ def _delete_session_sync(self, session_id: str) -> bool: if row is None: return False - connection.execute(f"DELETE FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", (session_id,)) - connection.execute(f"DELETE FROM {KSADK_STATES_TABLE} WHERE session_id = ?", (session_id,)) + connection.execute( + f"DELETE FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", (session_id,) + ) + connection.execute( + f"DELETE FROM {KSADK_STATES_TABLE} WHERE session_id = ?", (session_id,) + ) connection.execute(f"DELETE FROM {KSADK_SESSIONS_TABLE} WHERE id = ?", (session_id,)) connection.commit() return True @@ -541,7 +671,8 @@ def _append_event_sync(self, session_id: str, event: SessionEvent) -> SessionEve next_seq = int( connection.execute( - f"SELECT COALESCE(MAX(seq_id), 0) + 1 FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", + f"SELECT COALESCE(MAX(seq_id), 0) + 1 " + f"FROM {KSADK_EVENTS_TABLE} WHERE session_id = ?", (session_id,), ).fetchone()[0] ) diff --git a/ksadk/sessions/postgres_service.py b/ksadk/sessions/postgres_service.py index ed556069..cafef8fb 100644 --- a/ksadk/sessions/postgres_service.py +++ b/ksadk/sessions/postgres_service.py @@ -9,6 +9,7 @@ from typing import Any, Optional from urllib.parse import urlsplit, urlunsplit +from ksadk.ids import new_session_id from ksadk.sessions.base import ( BaseSessionService, Session, @@ -59,7 +60,7 @@ async def create_session( session_id: Optional[str] = None, ) -> Session: await self._ensure_schema() - session_key = session_id or generate_id() + session_key = session_id or new_session_id() async with self._pool.acquire() as connection: async with connection.transaction(): existing = await self._get_session_with_connection(connection, session_key) @@ -70,8 +71,9 @@ async def create_session( await connection.execute( f""" INSERT INTO {KSADK_PG_SESSIONS_TABLE} ( - namespace, tenant_id, workspace_id, id, agent_id, user_id, title, title_source, summary, - first_prompt, last_prompt, state_json, created_at, updated_at, version + namespace, tenant_id, workspace_id, id, agent_id, user_id, + title, title_source, summary, first_prompt, last_prompt, + state_json, created_at, updated_at, version ) VALUES ($1, $2, $3, $4, $5, $6, '', '', '', '', '', $7::jsonb, $8, $9, 0) ON CONFLICT (namespace, id) DO NOTHING @@ -96,7 +98,8 @@ async def create_session( await connection.execute( f""" INSERT INTO {KSADK_PG_STATES_TABLE} ( - namespace, tenant_id, workspace_id, scope, agent_id, user_id, session_id, state_json, version, updated_at + namespace, tenant_id, workspace_id, scope, agent_id, + user_id, session_id, state_json, version, updated_at ) VALUES ($1, $2, $3, 'session', $4, $5, $6, $7::jsonb, 0, $8) ON CONFLICT (namespace, scope, agent_id, user_id, session_id) @@ -118,6 +121,15 @@ async def get_session(self, session_id: str) -> Optional[Session]: async with self._pool.acquire() as connection: return await self._get_session_with_connection(connection, session_id) + async def get_session_metadata(self, session_id: str) -> Optional[Session]: + await self._ensure_schema() + async with self._pool.acquire() as connection: + return await self._get_session_with_connection( + connection, + session_id, + include_events=False, + ) + async def list_sessions( self, agent_id: str, @@ -128,7 +140,8 @@ async def list_sessions( await self._ensure_schema() async with self._pool.acquire() as connection: query = f""" - SELECT id, agent_id, user_id, title, title_source, summary, first_prompt, last_prompt, + SELECT id, agent_id, user_id, title, title_source, summary, + first_prompt, last_prompt, state_json, created_at, updated_at, version FROM {KSADK_PG_SESSIONS_TABLE} WHERE namespace = $1 AND agent_id = $2 @@ -137,7 +150,7 @@ async def list_sessions( if user_id is not None: params.append(user_id) query += f" AND user_id = ${len(params)}" - query += " ORDER BY updated_at DESC, created_at DESC" + query += " ORDER BY updated_at DESC, created_at DESC, id DESC" if limit is not None: params.append(limit) query += f" LIMIT ${len(params)}" @@ -205,7 +218,8 @@ async def update_session_metadata( async with connection.transaction(): row = await connection.fetchrow( f""" - SELECT id, agent_id, user_id, title, title_source, summary, first_prompt, last_prompt, + SELECT id, agent_id, user_id, title, title_source, summary, + first_prompt, last_prompt, state_json, created_at, updated_at, version FROM {KSADK_PG_SESSIONS_TABLE} WHERE namespace = $1 AND id = $2 @@ -294,10 +308,14 @@ async def append_event(self, session_id: str, event: SessionEvent) -> SessionEve await connection.execute( f""" INSERT INTO {KSADK_PG_EVENTS_TABLE} ( - namespace, tenant_id, workspace_id, id, session_id, author, event_type, content_json, timestamp, + namespace, tenant_id, workspace_id, id, session_id, author, + event_type, content_json, timestamp, state_delta_json, seq_id, invocation_id, metadata_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10::jsonb, $11, $12, $13::jsonb) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, + $10::jsonb, $11, $12, $13::jsonb + ) """, self.namespace, self.tenant_id, @@ -419,6 +437,85 @@ async def count_events( """ return int(await connection.fetchval(query, *params) or 0) + def _agent_events_query_parts( + self, + agent_id: str, + user_id: Optional[str], + ) -> tuple[str, list[Any]]: + """跨会话事件查询的公共 JOIN/WHERE。events 表无 agent_id 列,需 join sessions。""" + conditions = [ + "e.namespace = $1", + "s.namespace = e.namespace", + "s.id = e.session_id", + "s.agent_id = $2", + ] + params: list[Any] = [self.namespace, agent_id] + if user_id is not None: + params.append(user_id) + conditions.append(f"s.user_id = ${len(params)}") + where_clause = " AND ".join(conditions) + from_clause = ( + f"FROM {KSADK_PG_EVENTS_TABLE} e " + f"JOIN {KSADK_PG_SESSIONS_TABLE} s " + "ON s.namespace = e.namespace AND s.id = e.session_id " + f"WHERE {where_clause}" + ) + return from_clause, params + + async def get_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[SessionEvent]: + await self._ensure_schema() + from_clause, params = self._agent_events_query_parts(agent_id, user_id) + columns = ( + "e.id, e.session_id, e.author, e.event_type, e.content_json, e.timestamp, " + "e.state_delta_json, e.seq_id, e.invocation_id, e.metadata_json" + ) + async with self._pool.acquire() as connection: + if limit is not None or offset is not None: + # 与 get_events 一致的"最新 N 条"尾部语义:先 DESC 取窗口再 ASC 重排 + if limit is not None: + params.append(limit) + limit_sql = f"LIMIT ${len(params)}" + else: + limit_sql = "" + params.append(offset or 0) + offset_sql = f"OFFSET ${len(params)}" + query = f""" + SELECT id, session_id, author, event_type, content_json, timestamp, + state_delta_json, seq_id, invocation_id, metadata_json + FROM ( + SELECT {columns} + {from_clause} + ORDER BY e.timestamp DESC, e.seq_id DESC, e.id DESC + {limit_sql} {offset_sql} + ) AS latest_events + ORDER BY timestamp ASC, seq_id ASC, id ASC + """ + else: + query = f""" + SELECT {columns} + {from_clause} + ORDER BY e.timestamp ASC, e.seq_id ASC, e.id ASC + """ + rows = await connection.fetch(query, *params) + return [self._event_from_row(row) for row in rows] + + async def count_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + await self._ensure_schema() + from_clause, params = self._agent_events_query_parts(agent_id, user_id) + async with self._pool.acquire() as connection: + query = f"SELECT COUNT(*) AS total {from_clause}" + return int(await connection.fetchval(query, *params) or 0) + async def get_state( self, agent_id: str, @@ -445,7 +542,8 @@ async def get_state( f""" SELECT scope, agent_id, user_id, session_id, state_json, version, updated_at FROM {KSADK_PG_STATES_TABLE} - WHERE namespace = $1 AND scope = $2 AND agent_id = $3 AND user_id = $4 AND session_id = $5 + WHERE namespace = $1 AND scope = $2 AND agent_id = $3 + AND user_id = $4 AND session_id = $5 """, self.namespace, scope, @@ -506,7 +604,8 @@ async def update_state( f""" SELECT state_json, version FROM {KSADK_PG_STATES_TABLE} - WHERE namespace = $1 AND scope = $2 AND agent_id = $3 AND user_id = $4 AND session_id = $5 + WHERE namespace = $1 AND scope = $2 AND agent_id = $3 + AND user_id = $4 AND session_id = $5 FOR UPDATE """, self.namespace, @@ -521,7 +620,8 @@ async def update_state( await connection.execute( f""" INSERT INTO {KSADK_PG_STATES_TABLE} ( - namespace, tenant_id, workspace_id, scope, agent_id, user_id, session_id, state_json, version, updated_at + namespace, tenant_id, workspace_id, scope, agent_id, + user_id, session_id, state_json, version, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) ON CONFLICT (namespace, scope, agent_id, user_id, session_id) @@ -568,7 +668,7 @@ async def _ensure_pool(self) -> None: if self._pool is not None: return try: - import asyncpg + import asyncpg # type: ignore[import-untyped] except ImportError as exc: raise SessionBackendUnavailable( "asyncpg is required for KSADK_SESSION_BACKEND=postgres" @@ -595,8 +695,7 @@ async def _ensure_schema(self) -> None: return await self._ensure_pool() async with self._pool.acquire() as connection: - await connection.execute( - f""" + await connection.execute(f""" CREATE TABLE IF NOT EXISTS {KSADK_PG_SESSIONS_TABLE} ( namespace TEXT NOT NULL, tenant_id TEXT NOT NULL DEFAULT 'default', @@ -640,6 +739,18 @@ async def _ensure_schema(self) -> None: CREATE INDEX IF NOT EXISTS idx_ksadk_pg_events_session_seq ON {KSADK_PG_EVENTS_TABLE} (namespace, session_id, seq_id); + -- 跨会话事件查询(get_events_for_agent)需 JOIN sessions 按 + -- s.agent_id 过滤并按 e.timestamp 排序;events 表无 agent_id 列, + -- 覆盖索引 (namespace, session_id, timestamp, id) 服务 JOIN 键 + -- s.id=e.session_id + ORDER BY e.timestamp DESC。 + CREATE INDEX IF NOT EXISTS idx_ksadk_pg_events_session_ts + ON {KSADK_PG_EVENTS_TABLE} (namespace, session_id, timestamp, id); + + -- ListSessions 归并按 agent_id 过滤 + updated_at DESC 排序; + -- sessions 表 PK 是 (namespace, id),缺 agent_id 前缀索引。 + CREATE INDEX IF NOT EXISTS idx_ksadk_pg_sessions_agent_updated + ON {KSADK_PG_SESSIONS_TABLE} (namespace, agent_id, updated_at DESC, id); + CREATE TABLE IF NOT EXISTS {KSADK_PG_STATES_TABLE} ( namespace TEXT NOT NULL, tenant_id TEXT NOT NULL DEFAULT 'default', @@ -666,11 +777,9 @@ async def _ensure_schema(self) -> None: ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'; ALTER TABLE {KSADK_PG_STATES_TABLE} ADD COLUMN IF NOT EXISTS workspace_id TEXT NOT NULL DEFAULT 'default'; - """ - ) + """) try: - await connection.execute( - f""" + await connection.execute(f""" CREATE OR REPLACE VIEW {PG_READABLE_EVENTS_VIEW} AS SELECT event_row.namespace, @@ -715,8 +824,7 @@ async def _ensure_schema(self) -> None: JOIN {KSADK_PG_SESSIONS_TABLE} AS session_row ON session_row.namespace = event_row.namespace AND session_row.id = event_row.session_id; - """ - ) + """) except Exception as exc: logger.warning("Postgres readable session view unavailable: %s", exc) self._schema_ready = True @@ -727,6 +835,7 @@ async def _get_session_with_connection( session_id: str, *, for_update: bool = False, + include_events: bool = True, ) -> Optional[Session]: lock_clause = " FOR UPDATE" if for_update else "" row = await connection.fetchrow( @@ -742,10 +851,16 @@ async def _get_session_with_connection( ) if row is None: return None - events = [] if for_update else await self._get_events_with_connection(connection, session_id) + events = ( + await self._get_events_with_connection(connection, session_id) + if include_events and not for_update + else [] + ) return self._session_from_row(row, events=events) - async def _get_events_with_connection(self, connection: Any, session_id: str) -> list[SessionEvent]: + async def _get_events_with_connection( + self, connection: Any, session_id: str + ) -> list[SessionEvent]: rows = await connection.fetch( f""" SELECT id, session_id, author, event_type, content_json, timestamp, @@ -785,7 +900,8 @@ async def _write_session_state( await connection.execute( f""" INSERT INTO {KSADK_PG_STATES_TABLE} ( - namespace, tenant_id, workspace_id, scope, agent_id, user_id, session_id, state_json, version, updated_at + namespace, tenant_id, workspace_id, scope, agent_id, + user_id, session_id, state_json, version, updated_at ) VALUES ($1, $2, $3, 'session', $4, $5, $6, $7::jsonb, $8, $9) ON CONFLICT (namespace, scope, agent_id, user_id, session_id) @@ -888,7 +1004,9 @@ def mask_postgres_session_dsn(dsn: str) -> str: host = parts.hostname or "" port = f":{parts.port}" if parts.port else "" auth = f"{username}:***@" if username else "***@" - return urlunsplit((parts.scheme, f"{auth}{host}{port}", parts.path, parts.query, parts.fragment)) + return urlunsplit( + (parts.scheme, f"{auth}{host}{port}", parts.path, parts.query, parts.fragment) + ) __all__ = [ diff --git a/ksadk/sessions/resilient.py b/ksadk/sessions/resilient.py index a6df6279..6c5955f6 100644 --- a/ksadk/sessions/resilient.py +++ b/ksadk/sessions/resilient.py @@ -159,6 +159,12 @@ async def get_session(self, session_id: str) -> Optional[Session]: return await self._hydrate(durable) return live + async def get_session_metadata(self, session_id: str) -> Optional[Session]: + ok, durable = await self._call_primary("get_session_metadata", session_id) + if ok and durable is not None: + return cast(Session, durable) + return await self.fallback.get_session_metadata(session_id) + async def list_sessions( self, agent_id: str, @@ -174,16 +180,14 @@ async def list_sessions( limit, ) if ok: - for session in durable_sessions or []: - await self._hydrate(session) - return cast( - list[Session], - await self.fallback.list_sessions(agent_id, user_id, offset, limit), - ) + return cast(list[Session], durable_sessions or []) + return await self.fallback.list_sessions(agent_id, user_id, offset, limit) async def count_sessions(self, agent_id: str, user_id: Optional[str] = None) -> int: - sessions = await self.list_sessions(agent_id, user_id) - return len(sessions) + ok, total = await self._call_primary("count_sessions", agent_id, user_id) + if ok: + return int(total or 0) + return await self.fallback.count_sessions(agent_id, user_id) async def delete_session(self, session_id: str) -> bool: deleted = await self.fallback.delete_session(session_id) @@ -288,6 +292,30 @@ async def count_events( await self.fallback.count_events(session_id, after_seq_id, before_seq_id), ) + async def get_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[SessionEvent]: + ok, events = await self._call_primary( + "get_events_for_agent", agent_id, user_id, offset, limit + ) + if ok: + return cast(list[SessionEvent], events) + return await self.fallback.get_events_for_agent(agent_id, user_id, offset, limit) + + async def count_events_for_agent( + self, + agent_id: str, + user_id: Optional[str] = None, + ) -> int: + ok, total = await self._call_primary("count_events_for_agent", agent_id, user_id) + if ok: + return int(total or 0) + return await self.fallback.count_events_for_agent(agent_id, user_id) + async def get_state( self, agent_id: str, diff --git a/ksadk/skills/models.py b/ksadk/skills/models.py index b1b91136..ce78abad 100644 --- a/ksadk/skills/models.py +++ b/ksadk/skills/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass from typing import Any @@ -48,7 +49,9 @@ def from_payload(cls, payload: dict[str, Any]) -> "SkillRef": name=str(payload.get("Name") or payload.get("name") or ""), description=str(payload.get("Description") or payload.get("description") or ""), status=str(payload.get("Status") or payload.get("status") or ""), - content_hash=ContentHash.parse(payload.get("ContentHash") or payload.get("content_hash")), + content_hash=ContentHash.parse( + payload.get("ContentHash") or payload.get("content_hash") + ), archive_uri=str(payload.get("ArchiveUri") or payload.get("archive_uri") or ""), aliases=_string_tuple(payload.get("Aliases") or payload.get("aliases")), tags=_string_tuple(payload.get("Tags") or payload.get("tags")), @@ -65,7 +68,9 @@ def cache_key(self) -> str: version_key = self.version_id or self.version if not version_key and self.content_hash: version_key = self.content_hash.value - return "__".join(part.replace("/", "_").replace(":", "_") for part in (identity, version_key) if part) + return "__".join( + part.replace("/", "_").replace(":", "_") for part in (identity, version_key) if part + ) @property def is_active(self) -> bool: @@ -90,14 +95,20 @@ def from_payload( space_name: str = "", ) -> "SkillListResponse": data = payload.get("Data") or payload.get("data") or {} - skills_payload = data.get("Skills") or data.get("skills") or data.get("Items") or data.get("items") or [] + skills_payload = ( + data.get("Skills") or data.get("skills") or data.get("Items") or data.get("items") or [] + ) return cls( code=int(payload.get("Code") or payload.get("code") or 0), message=str(payload.get("Message") or payload.get("message") or ""), request_id=str(payload.get("RequestId") or payload.get("request_id") or ""), space_id=str(data.get("SkillSpaceId") or data.get("skill_space_id") or space_id), - space_name=str(data.get("SkillSpaceName") or data.get("skill_space_name") or space_name), - skills=[SkillRef.from_payload(item) for item in skills_payload if isinstance(item, dict)], + space_name=str( + data.get("SkillSpaceName") or data.get("skill_space_name") or space_name + ), + skills=[ + SkillRef.from_payload(item) for item in skills_payload if isinstance(item, dict) + ], ) def active_skills(self) -> list[SkillRef]: @@ -107,6 +118,7 @@ def active_skills(self) -> list[SkillRef]: def _string_tuple(raw: Any) -> tuple[str, ...]: if raw is None: return () + values: Iterable[Any] if isinstance(raw, str): values = raw.split(",") elif isinstance(raw, (list, tuple, set)): diff --git a/ksadk/skills/package_store.py b/ksadk/skills/package_store.py index 904115c1..69934d4d 100644 --- a/ksadk/skills/package_store.py +++ b/ksadk/skills/package_store.py @@ -79,11 +79,14 @@ def _verify_hash(self, ref: SkillRef, content: bytes) -> None: if not ref.content_hash: return if ref.content_hash.algorithm != "sha256": - raise SkillPackageError(f"Unsupported ContentHash algorithm: {ref.content_hash.algorithm}") + raise SkillPackageError( + f"Unsupported ContentHash algorithm: {ref.content_hash.algorithm}" + ) actual = hashlib.sha256(content).hexdigest() if actual.lower() != ref.content_hash.value.lower(): raise SkillPackageError( - f"ContentHash mismatch for {ref.name}: expected {ref.content_hash.render()}, got sha256:{actual}" + f"ContentHash mismatch for {ref.name}: expected " + f"{ref.content_hash.render()}, got sha256:{actual}" ) def _safe_extract(self, archive_path: Path, extract_dir: Path) -> None: diff --git a/ksadk/skills/runtime/agent.py b/ksadk/skills/runtime/agent.py index 5e906c4d..329728e1 100644 --- a/ksadk/skills/runtime/agent.py +++ b/ksadk/skills/runtime/agent.py @@ -2,7 +2,6 @@ import json import os -import subprocess import sys from dataclasses import asdict from pathlib import Path @@ -12,15 +11,29 @@ from ksadk.skills.loader import LocalSkill from ksadk.skills.models import SkillRef from ksadk.skills.package_store import PackageStore +from ksadk.skills.runtime import loader as runtime_loader +from ksadk.skills.runtime import registry as runtime_registry from ksadk.skills.runtime.base import normalize_skill_names from ksadk.skills.runtime.executor import ( WorkflowExecution, execute_workflow, +) +from ksadk.skills.runtime.executor import ( _can_run_web_artifacts_builder as _executor_can_run_web_artifacts_builder, +) +from ksadk.skills.runtime.executor import ( _run_command as _executor_run_command, +) +from ksadk.skills.runtime.executor import ( _run_web_artifacts_builder as _executor_run_web_artifacts_builder, +) +from ksadk.skills.runtime.executor import ( _runtime_timeout as _executor_runtime_timeout, +) +from ksadk.skills.runtime.executor import ( _safe_project_name as _executor_safe_project_name, +) +from ksadk.skills.runtime.executor import ( _tail as _executor_tail, ) from ksadk.skills.runtime.request import ( @@ -28,8 +41,6 @@ SkillWorkflowRequestError, parse_workflow_request, ) -from ksadk.skills.runtime import loader as runtime_loader -from ksadk.skills.runtime import registry as runtime_registry def _skill_space_ids() -> list[str]: @@ -109,7 +120,9 @@ def run_agent( print("workflow=") print(f"skill_spaces={','.join(_skill_space_ids())}") print("loaded_skills=") - print(f"workflow_result={json.dumps(asdict(execution), ensure_ascii=False, sort_keys=True)}") + print( + f"workflow_result={json.dumps(asdict(execution), ensure_ascii=False, sort_keys=True)}" + ) return 1 prompt = request.workflow_prompt @@ -125,7 +138,9 @@ def run_agent( print(f"loaded_skills={','.join(skill.name for skill in loaded_skills)}") print(f"selected_skills={json.dumps(selected_skill_names, ensure_ascii=False, sort_keys=True)}") if _SKILL_LOAD_WARNINGS: - print(f"skill_warnings={json.dumps(_SKILL_LOAD_WARNINGS, ensure_ascii=False, sort_keys=True)}") + print( + f"skill_warnings={json.dumps(_SKILL_LOAD_WARNINGS, ensure_ascii=False, sort_keys=True)}" + ) print(f"workflow_result={json.dumps(asdict(execution), ensure_ascii=False, sort_keys=True)}") return 0 if execution.status != "failed" else 1 diff --git a/ksadk/skills/runtime/artifacts.py b/ksadk/skills/runtime/artifacts.py index d56605b7..5508162d 100644 --- a/ksadk/skills/runtime/artifacts.py +++ b/ksadk/skills/runtime/artifacts.py @@ -20,11 +20,7 @@ def parse_artifact_lines(stdout: str) -> list[str]: def collect_output_dir_artifacts(output_dir: Path) -> list[str]: if not output_dir.is_dir(): return [] - return [ - str(path) - for path in sorted(output_dir.rglob("*")) - if path.is_file() - ] + return [str(path) for path in sorted(output_dir.rglob("*")) if path.is_file()] def merge_artifacts(*groups: list[str]) -> list[str]: diff --git a/ksadk/skills/runtime/backends/e2b.py b/ksadk/skills/runtime/backends/e2b.py index 8c6fb353..ebd83069 100644 --- a/ksadk/skills/runtime/backends/e2b.py +++ b/ksadk/skills/runtime/backends/e2b.py @@ -7,9 +7,11 @@ from ksadk.sandbox import ( E2BSandboxBackend, - SandboxInputFile as RuntimeSandboxInputFile, SandboxSpec, ) +from ksadk.sandbox import ( + SandboxInputFile as RuntimeSandboxInputFile, +) from ksadk.skills.runtime.base import ( SandboxInputFile, SkillRuntimeError, @@ -75,10 +77,10 @@ def __init__( @classmethod def from_env(cls) -> "E2BSkillRuntimeBackend": try: - from e2b import Sandbox + from e2b import Sandbox # type: ignore[import-untyped] except ImportError as exc: raise SkillRuntimeError( - "e2b>=2.0.0 is required for KSADK_SKILL_RUNTIME_BACKEND=e2b" + "e2b>=2.15.3,<2.25.0 is required for KSADK_SKILL_RUNTIME_BACKEND=e2b" ) from exc return cls( sandbox_cls=Sandbox, @@ -87,7 +89,11 @@ def from_env(cls) -> "E2BSkillRuntimeBackend": or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID") or "" ), - timeout=int(os.environ.get("KSADK_SANDBOX_TIMEOUT") or os.environ.get("KSADK_SKILL_RUNTIME_TIMEOUT") or "900"), + timeout=int( + os.environ.get("KSADK_SANDBOX_TIMEOUT") + or os.environ.get("KSADK_SKILL_RUNTIME_TIMEOUT") + or "900" + ), allow_internet_access=_bool_env( "KSADK_SANDBOX_ALLOW_INTERNET_ACCESS", _bool_env("KSADK_SKILL_RUNTIME_ALLOW_INTERNET_ACCESS", True), diff --git a/ksadk/skills/runtime/backends/local.py b/ksadk/skills/runtime/backends/local.py index ff99c15f..d9ab0cd9 100644 --- a/ksadk/skills/runtime/backends/local.py +++ b/ksadk/skills/runtime/backends/local.py @@ -17,6 +17,12 @@ ) +def _coerce_output(value: str | bytes | None) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value or "" + + class LocalProcessSkillRuntimeBackend: def __init__(self, agent_path: str | Path, timeout: int = 900): self.agent_path = Path(agent_path) @@ -67,7 +73,13 @@ def run_workflow( encoding="utf-8", ) completed = subprocess.run( - [sys.executable, "-u", str(self.agent_path), "--request-file", str(request_path)], + [ + sys.executable, + "-u", + str(self.agent_path), + "--request-file", + str(request_path), + ], text=True, capture_output=True, timeout=timeout or self.timeout, @@ -86,8 +98,8 @@ def run_workflow( return SkillRuntimeResult( runtime_id=f"local:{session_id}", exit_code=None, - stdout=exc.stdout or "", - stderr=exc.stderr or "", + stdout=_coerce_output(exc.stdout), + stderr=_coerce_output(exc.stderr), duration_ms=int((time.monotonic() - started) * 1000), timed_out=True, error_type="TimeoutExpired", diff --git a/ksadk/skills/runtime/base.py b/ksadk/skills/runtime/base.py index 848c4e08..2014c5cb 100644 --- a/ksadk/skills/runtime/base.py +++ b/ksadk/skills/runtime/base.py @@ -57,8 +57,7 @@ def run_workflow( env: dict[str, str] | None = None, input_files: list[SandboxInputFile] | None = None, timeout: int = 900, - ) -> SkillRuntimeResult: - ... + ) -> SkillRuntimeResult: ... def parse_output_files(stdout: str) -> list[str]: diff --git a/ksadk/skills/runtime/executor.py b/ksadk/skills/runtime/executor.py index f0130588..79fb599c 100644 --- a/ksadk/skills/runtime/executor.py +++ b/ksadk/skills/runtime/executor.py @@ -82,7 +82,10 @@ def _can_run_web_artifacts_builder(skill: LocalSkill, prompt: str) -> bool: if not init_script.exists() or not bundle_script.exists(): return False normalized = prompt.lower() - return any(marker in normalized for marker in ("web-artifacts-builder", "artifact", "bundle", "html", "react")) + return any( + marker in normalized + for marker in ("web-artifacts-builder", "artifact", "bundle", "html", "react") + ) def _can_run_generic_workflow(skill: LocalSkill) -> bool: @@ -91,7 +94,9 @@ def _can_run_generic_workflow(skill: LocalSkill) -> bool: def _run_web_artifacts_builder(skill: LocalSkill) -> WorkflowExecution: workdir = _skill_workdir() - project_name = _safe_project_name(os.environ.get("KSADK_SKILL_ARTIFACT_PROJECT") or "ksadk-artifact") + project_name = _safe_project_name( + os.environ.get("KSADK_SKILL_ARTIFACT_PROJECT") or "ksadk-artifact" + ) project_dir = workdir / project_name workdir.mkdir(parents=True, exist_ok=True) if project_dir.exists(): @@ -114,7 +119,9 @@ def _run_web_artifacts_builder(skill: LocalSkill) -> WorkflowExecution: timeout=timeout, ) commands.append(bundle_result) - output_files = [str(project_dir / "bundle.html")] if (project_dir / "bundle.html").exists() else [] + output_files = ( + [str(project_dir / "bundle.html")] if (project_dir / "bundle.html").exists() else [] + ) status = "ok" if bundle_result["exit_code"] == 0 and output_files else "failed" return WorkflowExecution( status=status, diff --git a/ksadk/skills/runtime/factory.py b/ksadk/skills/runtime/factory.py index 98729637..ce99f851 100644 --- a/ksadk/skills/runtime/factory.py +++ b/ksadk/skills/runtime/factory.py @@ -2,10 +2,10 @@ import os -from ksadk.skills.runtime.base import SkillRuntimeBackend from ksadk.skills.runtime.backends.disabled import DisabledSkillRuntimeBackend from ksadk.skills.runtime.backends.e2b import E2BSkillRuntimeBackend from ksadk.skills.runtime.backends.local import LocalProcessSkillRuntimeBackend +from ksadk.skills.runtime.base import SkillRuntimeBackend def _resolve_backend(backend: str | None = None) -> str: @@ -20,7 +20,9 @@ def _resolve_backend(backend: str | None = None) -> str: if sandbox_backend and sandbox_backend not in {"disabled", "none", "off"}: return sandbox_backend - if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID"): + if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get( + "KSADK_SKILL_RUNTIME_TEMPLATE_ID" + ): return "e2b" return "disabled" diff --git a/ksadk/skills/runtime/loader.py b/ksadk/skills/runtime/loader.py index 7202b715..51e39a16 100644 --- a/ksadk/skills/runtime/loader.py +++ b/ksadk/skills/runtime/loader.py @@ -37,8 +37,7 @@ def load_skills( return SkillLoadResult(skills=skills, warnings=warnings) cache_dir = Path( - os.environ.get("KSADK_SKILL_CACHE_DIR") - or Path(tempfile.gettempdir()) / "ksadk-skill-cache" + os.environ.get("KSADK_SKILL_CACHE_DIR") or Path(tempfile.gettempdir()) / "ksadk-skill-cache" ) client = SkillServiceClient( base_url=service_url, diff --git a/ksadk/skills/runtime/registry.py b/ksadk/skills/runtime/registry.py index 71f25164..ffc4ce7d 100644 --- a/ksadk/skills/runtime/registry.py +++ b/ksadk/skills/runtime/registry.py @@ -7,8 +7,14 @@ from ksadk.skills.runtime.base import normalize_skill_names from ksadk.skills.service_env import ( parse_skill_space_ids, +) +from ksadk.skills.service_env import ( public_skill_space_ids as configured_public_skill_space_ids, +) +from ksadk.skills.service_env import ( skill_space_ids as configured_skill_space_ids, +) +from ksadk.skills.service_env import ( user_skill_space_ids as configured_user_skill_space_ids, ) @@ -44,14 +50,13 @@ def dedupe_skill_refs(skill_refs: list[SkillRef], *, seen_names: set[str]) -> li def select_public_skill_refs(skill_refs: list[SkillRef]) -> list[SkillRef]: - allowlist = {name.lower() for name in normalize_skill_names(os.environ.get("KSADK_PUBLIC_SKILL_ALLOWLIST", ""))} + allowlist = { + name.lower() + for name in normalize_skill_names(os.environ.get("KSADK_PUBLIC_SKILL_ALLOWLIST", "")) + } if not allowlist: return [skill for skill in skill_refs if skill.name] - return [ - skill - for skill in skill_refs - if skill.name and skill.name.lower() in allowlist - ] + return [skill for skill in skill_refs if skill.name and skill.name.lower() in allowlist] def select_remote_skill_refs( @@ -118,7 +123,9 @@ def match_skill_refs( return matches -def _metadata_score(skill: SkillRef, normalized_prompt: str, prompt_tokens: set[str]) -> tuple[int, str]: +def _metadata_score( + skill: SkillRef, normalized_prompt: str, prompt_tokens: set[str] +) -> tuple[int, str]: if skill.name and skill.name.lower() in normalized_prompt: return 90, "name" for alias in skill.aliases: @@ -144,7 +151,5 @@ def _metadata_score(skill: SkillRef, normalized_prompt: str, prompt_tokens: set[ def _tokens(value: str) -> set[str]: return { - token - for token in re.split(r"[^0-9a-zA-Z\u4e00-\u9fff]+", value.lower()) - if len(token) >= 2 + token for token in re.split(r"[^0-9a-zA-Z\u4e00-\u9fff]+", value.lower()) if len(token) >= 2 } diff --git a/ksadk/skills/runtime/request.py b/ksadk/skills/runtime/request.py index 37a80d38..89633cb6 100644 --- a/ksadk/skills/runtime/request.py +++ b/ksadk/skills/runtime/request.py @@ -28,7 +28,9 @@ def parse_workflow_request(argv: list[str]) -> SkillWorkflowRequest: if has_request_file: return _request_from_json_file(_option_value(args, "--request-file")) if has_prompt_file: - return SkillWorkflowRequest(workflow_prompt=Path(_option_value(args, "--prompt-file")).read_text(encoding="utf-8")) + return SkillWorkflowRequest( + workflow_prompt=Path(_option_value(args, "--prompt-file")).read_text(encoding="utf-8") + ) if not args: return SkillWorkflowRequest() return SkillWorkflowRequest(workflow_prompt=args[0]) diff --git a/ksadk/skills/service_client.py b/ksadk/skills/service_client.py index b1720c2c..597c148b 100644 --- a/ksadk/skills/service_client.py +++ b/ksadk/skills/service_client.py @@ -9,9 +9,16 @@ import requests from ksadk.common.auth import AWSV4Auth - from ksadk.skills.models import SkillListResponse, SkillRef +_KOP_HOSTS = frozenset( + { + "aicp.inner.api.ksyun.com", + "aicp.internal.api.ksyun.com", + "aicp.api.ksyun.com", + } +) + class SkillServiceClient: def __init__( @@ -31,14 +38,24 @@ def __init__( ): self.base_url = _normalize_base_url(base_url) self.token = token - self.access_key = access_key or _env("KSADK_SKILL_SERVICE_ACCESS_KEY", "KSYUN_ACCESS_KEY", "KS3_ACCESS_KEY") - self.secret_key = secret_key or _env("KSADK_SKILL_SERVICE_SECRET_KEY", "KSYUN_SECRET_KEY", "KS3_SECRET_KEY") + self.access_key = access_key or _env( + "KSADK_SKILL_SERVICE_ACCESS_KEY", "KSYUN_ACCESS_KEY", "KS3_ACCESS_KEY" + ) + self.secret_key = secret_key or _env( + "KSADK_SKILL_SERVICE_SECRET_KEY", "KSYUN_SECRET_KEY", "KS3_SECRET_KEY" + ) self.account_id = account_id or _env("KSADK_SKILL_SERVICE_ACCOUNT_ID", "KSYUN_ACCOUNT_ID") - self.logical_region = region or _env("KSADK_SKILL_SERVICE_REGION", "KSYUN_REGION") or "cn-beijing-6" + self.logical_region = ( + region or _env("KSADK_SKILL_SERVICE_REGION", "KSYUN_REGION") or "cn-beijing-6" + ) self.region = _normalize_control_region(self.logical_region) self.custom_source = _resolve_custom_source(self.logical_region) - self.api_version = api_version or os.environ.get("KSADK_SKILL_SERVICE_API_VERSION", "2024-06-12") - self.sign_service = sign_service or os.environ.get("KSADK_SKILL_SERVICE_SIGN_SERVICE", "aicp") + self.api_version = api_version or os.environ.get( + "KSADK_SKILL_SERVICE_API_VERSION", "2024-06-12" + ) + self.sign_service = sign_service or os.environ.get( + "KSADK_SKILL_SERVICE_SIGN_SERVICE", "aicp" + ) self.extra_headers = dict(extra_headers or {}) self.timeout = timeout self.transport = transport @@ -76,7 +93,9 @@ def list_skills_by_space_id(self, space_id: str) -> SkillListResponse: def list_available_premade_skills(self) -> SkillListResponse: payload = self._get_json("ListAvailablePremadeSkills", {}) - return SkillListResponse.from_payload(payload, space_id="public", space_name="Public Skills") + return SkillListResponse.from_payload( + payload, space_id="public", space_name="Public Skills" + ) def get_skill_download_url(self, skill: SkillRef) -> str: action = "GetSkillDownloadUrl" if skill.version_id else "GetPremadeSkillDownloadUrl" @@ -97,7 +116,7 @@ def download_skill_archive(self, skill: SkillRef) -> bytes: with httpx.Client(**self._client_kwargs()) as client: response = client.get(download_url) response.raise_for_status() - return response.content + return bytes(response.content) def _get_json(self, action: str, params: dict[str, Any]) -> dict[str, Any]: if self._is_kop_mode(): @@ -169,15 +188,15 @@ def _get_json_kop(self, action: str, params: dict[str, Any]) -> dict[str, Any]: "Set KSADK_SKILL_SERVICE_ACCESS_KEY/KSADK_SKILL_SERVICE_SECRET_KEY " "or KSYUN_ACCESS_KEY/KSYUN_SECRET_KEY." ) - response = self._requests().get( + requests_response = self._requests().get( self._kop_base_url() + "/", params=query, headers=headers, auth=self._auth.get_auth(), timeout=self.timeout, ) - response.raise_for_status() - data = response.json() + requests_response.raise_for_status() + data = requests_response.json() if not isinstance(data, dict): raise ValueError(f"Skill Service returned non-object response for {action}") code = data.get("Code", data.get("code")) @@ -196,12 +215,14 @@ def _requests(self) -> requests.Session: return self._requests_session def _is_kop_mode(self) -> bool: - host = urlsplit(self.base_url).netloc.lower() - return ( - host.endswith("aicp.inner.api.ksyun.com") - or host.endswith("aicp.internal.api.ksyun.com") - or host.endswith("aicp.api.ksyun.com") - ) + """Match only exact, TLS-protected AICP control-plane endpoints. + + A user-controlled URL such as ``aicp.api.ksyun.com.attacker.example`` + must never receive KOP signing headers. + """ + parsed = urlsplit(self.base_url) + host = (parsed.hostname or "").lower() + return parsed.scheme == "https" and host in _KOP_HOSTS def _kop_base_url(self) -> str: parsed = urlsplit(self.base_url) diff --git a/ksadk/skills/tool_defs.py b/ksadk/skills/tool_defs.py index 32550fe2..f4d8e8c9 100644 --- a/ksadk/skills/tool_defs.py +++ b/ksadk/skills/tool_defs.py @@ -12,9 +12,11 @@ public_skill_space_ids, resolve_skill_service_url, should_resolve_child_skill_service_url, - skill_space_ids as configured_skill_space_ids, user_skill_space_ids, ) +from ksadk.skills.service_env import ( + skill_space_ids as configured_skill_space_ids, +) RUNTIME_AGENT_ENV_NAMES = ( "KSADK_SKILL_SERVICE_URL", @@ -51,11 +53,7 @@ def _parse_skill_space_ids(*raw_values: str) -> list[str]: def runtime_agent_env_from_process(skill_space_ids: list[str] | None = None) -> dict[str, str]: - env = { - name: value - for name in RUNTIME_AGENT_ENV_NAMES - if (value := os.environ.get(name)) - } + env = {name: value for name in RUNTIME_AGENT_ENV_NAMES if (value := os.environ.get(name))} if "KSADK_SKILL_SERVICE_ACCOUNT_ID" not in env and ( account_id := os.environ.get("KSYUN_ACCOUNT_ID") ): @@ -72,7 +70,11 @@ def runtime_agent_env_from_process(skill_space_ids: list[str] | None = None) -> if value: env[target] = value break - if has_spaces and should_resolve_child_skill_service_url() and "KSADK_SKILL_SERVICE_URL" not in env: + if ( + has_spaces + and should_resolve_child_skill_service_url() + and "KSADK_SKILL_SERVICE_URL" not in env + ): service_url = resolve_skill_service_url(require_spaces=False) if service_url: env["KSADK_SKILL_SERVICE_URL"] = service_url @@ -125,7 +127,8 @@ def load_remote_skill_manifests(skill_space_ids: list[str] | None = None) -> lis seen: set[str] = set() limit = _manifest_limit() public_allowlist = { - name.lower() for name in normalize_skill_names(os.environ.get("KSADK_PUBLIC_SKILL_ALLOWLIST", "")) + name.lower() + for name in normalize_skill_names(os.environ.get("KSADK_PUBLIC_SKILL_ALLOWLIST", "")) } for space_id in user_spaces: listing = client.list_skills_by_space_id(space_id) @@ -161,8 +164,10 @@ def build_skill_manifest_instruction(manifests: list[dict[str, str]]) -> str: lines = [ "", "Available remote skills are listed below. Use them only when they match the user's task.", - "When a skill is useful, call execute_skills with the original workflow_prompt and skill_names set to the exact skill name.", - "Do not assume full skill instructions are already loaded; execute_skills loads selected skills on demand.", + "When a skill is useful, call execute_skills with the original workflow_prompt " + "and skill_names set to the exact skill name.", + "Do not assume full skill instructions are already loaded; execute_skills loads " + "selected skills on demand.", "", "Remote skills:", ] diff --git a/ksadk/terminal_exec_policy.py b/ksadk/terminal_exec_policy.py index 22ead99b..6f6aa3a8 100644 --- a/ksadk/terminal_exec_policy.py +++ b/ksadk/terminal_exec_policy.py @@ -140,10 +140,9 @@ def validate_terminal_exec_argv( normalized = normalize_exec_argv(argv, policy_name=policy.name) allowed_exact = tuple(tuple(item) for item in policy.default_exact) - allowed_prefixes = ( - tuple(tuple(item) for item in policy.default_prefixes) - + _env_allowlist_prefixes(policy.env_names, policy_name=policy.name) - ) + allowed_prefixes = tuple( + tuple(item) for item in policy.default_prefixes + ) + _env_allowlist_prefixes(policy.env_names, policy_name=policy.name) if _matches_exact_or_prefix( normalized, diff --git a/ksadk/tools/gateway.py b/ksadk/tools/gateway.py index bd282b87..47d3fdaa 100644 --- a/ksadk/tools/gateway.py +++ b/ksadk/tools/gateway.py @@ -1,19 +1,28 @@ from __future__ import annotations -import os import hashlib import json +import os import shlex from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import Any from uuid import uuid4 +from ksadk.runtime_context import get_current_invocation_context + @dataclass(frozen=True) class ToolPolicy: risk_level: str = "low" side_effects: Sequence[str] = field(default_factory=tuple) + # Approval scopes classify operations which might need an interactive + # decision. ``public_network`` is intentionally a read-only exception: + # web discovery must remain available in every approval profile. + approval_scopes: Sequence[str] = field(default_factory=tuple) + # Use only for tools whose operation is inherently safe to run without a + # human decision. This wins over a risk level or explicit legacy policy. + approval_exempt: bool = False requires_approval: bool | None = None @@ -50,8 +59,9 @@ def _requires_approval(self, policy: ToolPolicy) -> bool: @staticmethod def _approval_mode() -> str: - value = os.environ.get("KSADK_TOOL_APPROVAL_MODE", "").strip().lower() - return value or "off" + context = get_current_invocation_context() + requested_mode = context.tool_approval_mode if context is not None else None + return normalize_tool_approval_mode(requested_mode) @staticmethod def _is_approved(approval: Mapping[str, Any] | None) -> bool: @@ -108,24 +118,76 @@ def tool_policy_requires_approval( *, approval_mode: str | None = None, ) -> bool: - mode = (approval_mode or os.environ.get("KSADK_TOOL_APPROVAL_MODE", "")).strip().lower() or "off" + mode = normalize_tool_approval_mode(approval_mode) + if policy.approval_exempt or _is_read_only_public_network_policy(policy): + return False if policy.requires_approval is not None: - return policy.requires_approval and mode != "off" - if mode != "strict": + return policy.requires_approval and mode != "full" + if mode == "full": return False + if mode == "ask" and policy.approval_scopes: + return True return policy.risk_level.lower() in {"medium", "high", "critical"} +def _is_read_only_public_network_policy(policy: ToolPolicy) -> bool: + """Keep public web reads available even under the most cautious profile. + + A network write must declare a side effect, so it does not match this + exemption and can still require approval. + """ + return bool(policy.approval_scopes) and set(policy.approval_scopes) <= { + "public_network" + } and not policy.side_effects + + +def normalize_tool_approval_mode(value: str | None = None) -> str: + """Resolve the compact runtime approval profile. + + ``ask`` confirms risky and non-network scoped operations; ``risk`` + confirms medium-and-higher risk only; ``full`` leaves default policies + unprompted. Public web reads remain approval-free in every profile. The + process environment remains a default for non-UI callers, while a + request-scoped mode wins for the duration of that invocation. + """ + raw = str(value or os.environ.get("KSADK_TOOL_APPROVAL_MODE", "risk")).strip().lower() + return raw if raw in {"ask", "risk", "full"} else "risk" + + +def tool_approval_capability() -> dict[str, Any]: + """Describe the single profile interface exposed to hosted UIs.""" + return { + "Modes": ["ask", "risk", "full"], + "DefaultMode": normalize_tool_approval_mode(), + "RuntimeOverride": True, + } + + def check_command_policy(command: str) -> dict[str, Any]: text = str(command or "").strip() if not text: - return {"ok": False, "decision": "reject", "error_type": "command_required", "error_message": "command is required"} + return { + "ok": False, + "decision": "reject", + "error_type": "command_required", + "error_message": "command is required", + } try: tokens = shlex.split(text) except ValueError as exc: - return {"ok": False, "decision": "reject", "error_type": "invalid_command", "error_message": str(exc)} + return { + "ok": False, + "decision": "reject", + "error_type": "invalid_command", + "error_message": str(exc), + } if not tokens: - return {"ok": False, "decision": "reject", "error_type": "command_required", "error_message": "command is required"} + return { + "ok": False, + "decision": "reject", + "error_type": "command_required", + "error_message": "command is required", + } if _contains_recursive_rm(tokens): return _command_rejected("recursive rm is not allowed without explicit approval") if any(token in _DANGEROUS_COMMAND_TOKENS for token in tokens): @@ -137,12 +199,19 @@ def check_command_policy(command: str) -> dict[str, Any]: if subcommand in _DANGEROUS_GIT_COMMANDS: return _command_rejected(f"git {subcommand} is not allowed without explicit approval") if _references_metadata_endpoint(tokens): - return _command_rejected("metadata/private endpoint access is not allowed by default policy") + return _command_rejected( + "metadata/private endpoint access is not allowed by default policy" + ) return {"ok": True, "decision": "allow", "reason": "default_allow"} def _command_rejected(message: str) -> dict[str, Any]: - return {"ok": False, "decision": "reject", "error_type": "command_rejected", "error_message": message} + return { + "ok": False, + "decision": "reject", + "error_type": "command_rejected", + "error_message": message, + } def _contains_recursive_rm(tokens: Sequence[str]) -> bool: @@ -162,7 +231,9 @@ def _references_metadata_endpoint(tokens: Sequence[str]) -> bool: def _canonical_tool_args(tool_args: Any) -> str: try: - return json.dumps(tool_args or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return json.dumps( + tool_args or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) except TypeError: return json.dumps(str(tool_args), ensure_ascii=False, separators=(",", ":")) @@ -185,7 +256,9 @@ def build_tool_receipt_idempotency_key( "tool_args": _canonical_tool_args(tool_args), } digest = hashlib.sha256( - json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) ).hexdigest() return f"tool_receipt:{digest}" @@ -222,8 +295,12 @@ def approval_interrupt_info_from_result( or {} ), "risk_level": str(approval_request.get("risk_level") or result.get("risk_level") or ""), - "side_effects": list(approval_request.get("side_effects") or result.get("side_effects") or []), - "server_label": str(approval_request.get("server_label") or result.get("server_label") or "ksadk"), + "side_effects": list( + approval_request.get("side_effects") or result.get("side_effects") or [] + ), + "server_label": str( + approval_request.get("server_label") or result.get("server_label") or "ksadk" + ), } if run_id: interrupt["run_id"] = str(run_id) diff --git a/ksadk/tools/result_budget.py b/ksadk/tools/result_budget.py index c16a816d..49ad7d75 100644 --- a/ksadk/tools/result_budget.py +++ b/ksadk/tools/result_budget.py @@ -10,7 +10,6 @@ from ksadk.sessions.local_service import resolve_local_session_dir - _SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+") @@ -58,7 +57,10 @@ def budget_tool_output( active_budget = budget or default_tool_result_budget() text, mime_type, extension = _stringify_value(value) original_chars = len(text) - should_persist = original_chars > active_budget.persist_threshold_chars or original_chars > active_budget.max_chars + should_persist = ( + original_chars > active_budget.persist_threshold_chars + or original_chars > active_budget.max_chars + ) preview_limit = max(0, min(active_budget.preview_chars, active_budget.max_chars)) preview = text[:preview_limit] if should_persist else text[: active_budget.max_chars] result: dict[str, Any] = { @@ -126,7 +128,9 @@ def _persist_tool_output( or metadata.get("id") or uuid4().hex ) - stem = _safe_filename(f"{tool_use_id}.{field_name}") or _safe_filename(f"{tool_name}.{field_name}.{uuid4().hex}") + stem = _safe_filename(f"{tool_use_id}.{field_name}") or _safe_filename( + f"{tool_name}.{field_name}.{uuid4().hex}" + ) path = (persist_dir / f"{stem}.{extension}").resolve() if persist_dir not in path.parents and path != persist_dir: raise ValueError("tool result path must stay inside persist_dir") @@ -137,7 +141,11 @@ def _persist_tool_output( def _stringify_value(value: Any) -> tuple[str, str, str]: if isinstance(value, str): return value, "text/plain", "txt" - return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), "application/json", "json" + return ( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), + "application/json", + "json", + ) def _safe_filename(value: str) -> str: diff --git a/ksadk/toolsets/__init__.py b/ksadk/toolsets/__init__.py index 7daeeecb..af8f395c 100644 --- a/ksadk/toolsets/__init__.py +++ b/ksadk/toolsets/__init__.py @@ -3,34 +3,36 @@ import json import os import re -from collections.abc import Iterable -from collections.abc import Mapping +from collections.abc import Callable, Iterable, Mapping from typing import Any -from ksadk.toolsets.platform import get_platform_tools -from ksadk.toolsets.platform import component_status -from ksadk.toolsets.sandbox import get_sandbox_tools +from ksadk.tools.gateway import ToolPolicy, tool_policy_requires_approval +from ksadk.toolsets._langchain import as_tool +from ksadk.toolsets.platform import component_status, get_platform_tools from ksadk.toolsets.sandbox import ( _SANDBOX_TOOL_POLICIES, + get_sandbox_tools, run_code, run_command, sandbox_backend_name, sandbox_status, ) -from ksadk.toolsets.skills import get_skill_tools from ksadk.toolsets.skills import ( _SKILL_TOOL_POLICIES, _skill_execution_backend, execute_skills, + get_skill_tools, + list_skill_spaces, list_skills, load_skill, search_skills, ) -from ksadk.toolsets.workspace import get_workspace_tools +from ksadk.toolsets.web import _WEB_TOOL_POLICIES, get_web_tools, web_fetch, web_search from ksadk.toolsets.workspace import ( _WORKSPACE_TOOL_POLICIES, delete_workspace_file, edit_workspace_file, + get_workspace_tools, lint_workspace_file, list_workspace_files, multi_edit_workspace_file, @@ -40,10 +42,6 @@ write_workspace_file, write_workspace_files, ) -from ksadk.toolsets.web import get_web_tools -from ksadk.toolsets.web import web_fetch, web_search, _WEB_TOOL_POLICIES -from ksadk.tools.gateway import ToolPolicy, tool_policy_requires_approval -from ksadk.toolsets._langchain import as_tool _DEFAULT_GROUPS = ("skill", "workspace", "platform", "sandbox", "web") _DISPATCHER_TOOL_NAME = "tool_dispatcher" @@ -79,9 +77,12 @@ "web": get_web_tools, } -_TOOLSET_DESCRIPTORS = { +_TOOLSET_DESCRIPTORS: dict[ + str, tuple[tuple[Callable[..., Any], ToolPolicy, dict[str, Any]], ...] +] = { "skill": ( (list_skills, _SKILL_TOOL_POLICIES["list_skills"], {}), + (list_skill_spaces, _SKILL_TOOL_POLICIES["list_skill_spaces"], {}), (search_skills, _SKILL_TOOL_POLICIES["search_skills"], {}), (load_skill, _SKILL_TOOL_POLICIES["load_skill"], {}), ( @@ -95,20 +96,58 @@ ), ), "workspace": ( - (workspace_status, _WORKSPACE_TOOL_POLICIES["workspace_status"], {"boundary": "workspace_root"}), - (list_workspace_files, _WORKSPACE_TOOL_POLICIES["list_workspace_files"], {"boundary": "workspace_root"}), - (read_workspace_file, _WORKSPACE_TOOL_POLICIES["read_workspace_file"], {"boundary": "workspace_root"}), - (write_workspace_file, _WORKSPACE_TOOL_POLICIES["write_workspace_file"], {"boundary": "workspace_root"}), - (write_workspace_files, _WORKSPACE_TOOL_POLICIES["write_workspace_files"], {"boundary": "workspace_root"}), - (edit_workspace_file, _WORKSPACE_TOOL_POLICIES["edit_workspace_file"], {"boundary": "workspace_root"}), - (multi_edit_workspace_file, _WORKSPACE_TOOL_POLICIES["multi_edit_workspace_file"], {"boundary": "workspace_root"}), - (lint_workspace_file, _WORKSPACE_TOOL_POLICIES["lint_workspace_file"], {"boundary": "workspace_root"}), - (search_workspace_files, _WORKSPACE_TOOL_POLICIES["search_workspace_files"], {"boundary": "workspace_root"}), - (delete_workspace_file, _WORKSPACE_TOOL_POLICIES["delete_workspace_file"], {"boundary": "workspace_root"}), - ), - "platform": ( - (component_status, ToolPolicy(risk_level="low"), {}), + ( + workspace_status, + _WORKSPACE_TOOL_POLICIES["workspace_status"], + {"boundary": "workspace_root"}, + ), + ( + list_workspace_files, + _WORKSPACE_TOOL_POLICIES["list_workspace_files"], + {"boundary": "workspace_root"}, + ), + ( + read_workspace_file, + _WORKSPACE_TOOL_POLICIES["read_workspace_file"], + {"boundary": "workspace_root"}, + ), + ( + write_workspace_file, + _WORKSPACE_TOOL_POLICIES["write_workspace_file"], + {"boundary": "workspace_root"}, + ), + ( + write_workspace_files, + _WORKSPACE_TOOL_POLICIES["write_workspace_files"], + {"boundary": "workspace_root"}, + ), + ( + edit_workspace_file, + _WORKSPACE_TOOL_POLICIES["edit_workspace_file"], + {"boundary": "workspace_root"}, + ), + ( + multi_edit_workspace_file, + _WORKSPACE_TOOL_POLICIES["multi_edit_workspace_file"], + {"boundary": "workspace_root"}, + ), + ( + lint_workspace_file, + _WORKSPACE_TOOL_POLICIES["lint_workspace_file"], + {"boundary": "workspace_root"}, + ), + ( + search_workspace_files, + _WORKSPACE_TOOL_POLICIES["search_workspace_files"], + {"boundary": "workspace_root"}, + ), + ( + delete_workspace_file, + _WORKSPACE_TOOL_POLICIES["delete_workspace_file"], + {"boundary": "workspace_root"}, + ), ), + "platform": ((component_status, ToolPolicy(risk_level="low"), {}),), "sandbox": ( ( sandbox_status, @@ -218,9 +257,38 @@ def tool_dispatcher( include: str | Iterable[str] | None = None, profile: str = "default", ) -> dict[str, Any]: - """List, describe, or call ksadk built-in tools through one governed entrypoint.""" + """List, describe, or call ksadk built-in tools through one governed entrypoint. + + Args: + action: One of "list" (list available tools), "describe" (get a tool's + spec), or "call" (execute a tool). Common synonyms "call_tool", + "execute", "run" are auto-mapped to "call"; "get" to "describe". + tool_name: Required for "describe" and "call"; ignored for "list". + arguments: Tool arguments (dict or JSON string); used with "call". + include: Comma-separated tool groups to scope (e.g. "skill,sandbox"). + profile: Tool profile name (default: "default"). + """ normalized_action = str(action or "").strip().lower() + # 兼容 LLM 常见同义词,避免 "call_tool"/"execute"/"run" 等导致 unknown_action。 + _ACTION_SYNONYMS = { + "call_tool": "call", + "execute": "call", + "run": "call", + "invoke": "call", + "get": "describe", + "info": "describe", + "ls": "list", + "search": "list", + } + normalized_action = _ACTION_SYNONYMS.get(normalized_action, normalized_action) + # LLM 常把 tool_name 塞进 action(如 "list_skills"/"search_skills"/"run_command")。 + # 如果 action 不是合法值,无论 tool_name 是否有值,都自动当成 "call": + # 有 tool_name → 直接 call; 无 tool_name → 把 action 值当 tool_name 再 call。 + if normalized_action not in ("list", "describe", "call"): + if not tool_name: + tool_name = normalized_action + normalized_action = "call" requested_include = _normalize_include(include) if normalized_action == "list": @@ -231,17 +299,25 @@ def tool_dispatcher( include_dispatcher=False, ) except ValueError: - return _unknown_tool_error(", ".join(requested_include) if requested_include else str(include or "")) + return _unknown_tool_error( + ", ".join(requested_include) if requested_include else str(include or "") + ) return {"ok": True, "tools": specs, "tool_count": len(specs)} if normalized_action == "describe": target_name = _normalize_tool_name(tool_name) if not target_name: - return {"ok": False, "error_type": "missing_tool_name", "error_message": "tool_name is required"} + return { + "ok": False, + "error_type": "missing_tool_name", + "error_message": "tool_name is required", + } if target_name in {_DISPATCHER_TOOL_NAME, _LEGACY_DISPATCHER_TOOL_NAME}: return _dispatcher_self_call_error() try: - _, specs = _select_agentengine_tools(include=[target_name], profile=profile, include_dispatcher=False) + _, specs = _select_agentengine_tools( + include=[target_name], profile=profile, include_dispatcher=False + ) except ValueError: return _unknown_tool_error(target_name) return {"ok": True, "tool": specs[0]} @@ -249,15 +325,26 @@ def tool_dispatcher( if normalized_action == "call": target_name = _normalize_tool_name(tool_name) if not target_name: - return {"ok": False, "error_type": "missing_tool_name", "error_message": "tool_name is required"} + return { + "ok": False, + "error_type": "missing_tool_name", + "error_message": "tool_name is required", + } if target_name in {_DISPATCHER_TOOL_NAME, _LEGACY_DISPATCHER_TOOL_NAME}: return _dispatcher_self_call_error() try: - tools, specs = _select_agentengine_tools(include=[target_name], profile=profile, include_dispatcher=False) + tools, specs = _select_agentengine_tools( + include=[target_name], profile=profile, include_dispatcher=False + ) except ValueError: return _unknown_tool_error(target_name) if specs and specs[0].get("enabled") is False: - return {"ok": False, "error_type": "tool_disabled", "error_message": f"Tool is disabled: {target_name}", "tool_name": target_name} + return { + "ok": False, + "error_type": "tool_disabled", + "error_message": f"Tool is disabled: {target_name}", + "tool_name": target_name, + } tool_arguments, arguments_error = _normalize_tool_arguments(arguments) if arguments_error: return arguments_error @@ -429,7 +516,9 @@ def _expand_requested_names(requested: list[str], tool_registry: Mapping[str, An return names -def _expand_requested_descriptor_names(requested: list[str], descriptor_registry: Mapping[str, Any]) -> list[str]: +def _expand_requested_descriptor_names( + requested: list[str], descriptor_registry: Mapping[str, Any] +) -> list[str]: names: list[str] = [] for name in requested: canonical_name = _canonical_toolset_group(name) @@ -560,7 +649,9 @@ def _normalize_tool_name(tool_name: str | None) -> str: return str(tool_name or "").strip() -def _normalize_tool_arguments(arguments: dict[str, Any] | str | None) -> tuple[dict[str, Any], dict[str, Any] | None]: +def _normalize_tool_arguments( + arguments: dict[str, Any] | str | None, +) -> tuple[dict[str, Any], dict[str, Any] | None]: if arguments is None: return {}, None if isinstance(arguments, dict): @@ -594,7 +685,9 @@ def _normalize_tool_arguments(arguments: dict[str, Any] | str | None) -> tuple[d } -def _normalize_dispatched_tool_arguments(tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: +def _normalize_dispatched_tool_arguments( + tool_name: str, arguments: dict[str, Any] +) -> dict[str, Any]: if tool_name == "save_memory" and "content" not in arguments: if "key" in arguments and "value" in arguments: return {"content": f"{arguments['key']}: {_stringify_memory_value(arguments['value'])}"} @@ -819,13 +912,18 @@ def _tool_spec( "risk_level": policy.risk_level, "requires_approval": tool_policy_requires_approval(policy), "side_effects": list(policy.side_effects), + "approval_scopes": list(policy.approval_scopes), + "approval_exempt": policy.approval_exempt, "enabled": True, } for key, value in dict(extras or {}).items(): spec[key] = value() if callable(value) else value enabled = bool(spec.get("enabled", True)) if name == "web_search": - enabled = bool(os.environ.get("KSADK_WEB_SEARCH_PROVIDER") or os.environ.get("OPENCLAW_WEB_SEARCH_PROVIDER")) + enabled = bool( + os.environ.get("KSADK_WEB_SEARCH_PROVIDER") + or os.environ.get("OPENCLAW_WEB_SEARCH_PROVIDER") + ) if name in {"run_command", "run_code", "sandbox_status"}: enabled = enabled and _enabled_backend(sandbox_backend_name()) spec["enabled"] = enabled diff --git a/ksadk/toolsets/platform.py b/ksadk/toolsets/platform.py index 5689babf..a952b54b 100644 --- a/ksadk/toolsets/platform.py +++ b/ksadk/toolsets/platform.py @@ -34,8 +34,16 @@ def component_status() -> dict: "knowledge_base_bound": bool(_env("KSADK_KB_DATASET_ID")), "long_term_memory_bound": bool(_env("KSADK_LTM_NAMESPACE")), "skill_space_bound": bool(spaces), - "isolated_execution": "enabled" if skill_runtime_backend not in {"", "disabled", "none", "off"} else "not_enabled", - "sandbox_direct_tools": "enabled" if sandbox_backend not in {"", "disabled", "none", "off"} else "not_enabled", + "isolated_execution": ( + "enabled" + if skill_runtime_backend not in {"", "disabled", "none", "off"} + else "not_enabled" + ), + "sandbox_direct_tools": ( + "enabled" + if sandbox_backend not in {"", "disabled", "none", "off"} + else "not_enabled" + ), }, "skill_space": { "space_ids": spaces, @@ -48,13 +56,17 @@ def component_status() -> dict: "skill_runtime": { "backend": skill_runtime_backend, "enabled": skill_runtime_backend not in {"", "disabled", "none", "off"}, - "template_bound": bool(_env("KSADK_SANDBOX_TEMPLATE_ID") or _env("KSADK_SKILL_RUNTIME_TEMPLATE_ID")), + "template_bound": bool( + _env("KSADK_SANDBOX_TEMPLATE_ID") or _env("KSADK_SKILL_RUNTIME_TEMPLATE_ID") + ), "request_protocol": "--request-file JSON envelope", }, "sandbox": { "backend": sandbox_backend, "enabled": sandbox_backend not in {"", "disabled", "none", "off"}, - "template_bound": bool(_env("KSADK_SANDBOX_TEMPLATE_ID") or _env("KSADK_SKILL_RUNTIME_TEMPLATE_ID")), + "template_bound": bool( + _env("KSADK_SANDBOX_TEMPLATE_ID") or _env("KSADK_SKILL_RUNTIME_TEMPLATE_ID") + ), "tools": ["sandbox_status", "run_command", "run_code"], }, "workspace": { @@ -71,7 +83,11 @@ def component_status() -> dict: "delete_workspace_file", ], "boundary": "Workspace tools are confined to the AgentEngine UI workspace directory.", - "editing_model": "write tools support whole-file writes; edit_workspace_file supports exact snippet replacement; lint_workspace_file provides lightweight built-in checks.", + "editing_model": ( + "write tools support whole-file writes; edit_workspace_file supports " + "exact snippet replacement; lint_workspace_file provides lightweight " + "built-in checks." + ), }, } diff --git a/ksadk/toolsets/sandbox.py b/ksadk/toolsets/sandbox.py index 0e2103f2..9661a13a 100644 --- a/ksadk/toolsets/sandbox.py +++ b/ksadk/toolsets/sandbox.py @@ -1,9 +1,9 @@ from __future__ import annotations +import logging import os import shlex import time -import logging from typing import Any from uuid import uuid4 @@ -15,7 +15,6 @@ from ksadk.toolsets._langchain import as_tool from ksadk.toolsets.workspace import workspace_root - logger = logging.getLogger(__name__) @@ -30,11 +29,19 @@ def _gateway(): return default_tool_gateway(_SANDBOX_TOOL_POLICIES) +def _dict_result(value: Any, *, tool_name: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError(f"{tool_name} returned {type(value).__name__}, expected dict") + return dict(value) + + def sandbox_backend_name() -> str: explicit = os.environ.get("KSADK_SANDBOX_BACKEND", "").strip().lower() if explicit: return explicit - if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID"): + if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get( + "KSADK_SKILL_RUNTIME_TEMPLATE_ID" + ): return "e2b" return "none" @@ -47,15 +54,26 @@ def _sandbox_execution_backend_name() -> str: def sandbox_status() -> dict: """Report configured AgentEngine sandbox status and boundaries.""" - return _gateway().invoke("sandbox_status", _sandbox_status_impl) + return _dict_result( + _gateway().invoke("sandbox_status", _sandbox_status_impl), + tool_name="sandbox_status", + ) def _sandbox_status_impl() -> dict[str, Any]: backend = sandbox_backend_name() - timeout = int(os.environ.get("KSADK_SANDBOX_TIMEOUT") or os.environ.get("KSADK_SKILL_RUNTIME_TIMEOUT") or "600") + timeout = int( + os.environ.get("KSADK_SANDBOX_TIMEOUT") + or os.environ.get("KSADK_SKILL_RUNTIME_TIMEOUT") + or "600" + ) idle_ttl = _int_env("KSADK_SANDBOX_IDLE_TTL_SECONDS", 300) max_sessions = _int_env("KSADK_SANDBOX_MAX_SESSIONS", 0) - template_id = os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID") or "" + template_id = ( + os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") + or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID") + or "" + ) isolated = backend not in {"local", "local_process", "pod", "pod_process"} latest = GLOBAL_SANDBOX_REGISTRY.latest() now = time.time() @@ -89,7 +107,19 @@ def run_command( ) -> dict[str, Any]: """Run a shell command inside the configured isolated sandbox.""" - return _gateway().invoke("run_command", _run_command_impl, command, cwd, timeout, env, background, approval=approval) + return _dict_result( + _gateway().invoke( + "run_command", + _run_command_impl, + command, + cwd, + timeout, + env, + background, + approval=approval, + ), + tool_name="run_command", + ) def _run_command_impl( @@ -102,11 +132,15 @@ def _run_command_impl( command_text = str(command or "").strip() if not command_text: return {"ok": False, "error_message": "command is required"} - policy = check_command_policy(command_text) + policy = _dict_result(check_command_policy(command_text), tool_name="check_command_policy") if not policy.get("ok"): return policy if background: - return {"ok": False, "error_type": "background_not_supported", "error_message": "background commands are not supported in P0"} + return { + "ok": False, + "error_type": "background_not_supported", + "error_message": "background commands are not supported in P0", + } try: entry, _created = _sandbox_entry("ksadk-direct") result = entry.session.run_command(command_text, timeout=timeout, env=env, cwd=cwd) @@ -119,7 +153,15 @@ def _run_command_impl( "stderr": result.stderr, "exit_code": result.exit_code, } - return budget_text_fields(payload, tool_name="run_command", fields=("stdout", "stderr"), metadata={"tool_use_id": entry.sandbox_id}) + return _dict_result( + budget_text_fields( + payload, + tool_name="run_command", + fields=("stdout", "stderr"), + metadata={"tool_use_id": entry.sandbox_id}, + ), + tool_name="run_command", + ) except SandboxError as exc: return {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} except Exception as exc: @@ -135,7 +177,12 @@ def run_code( ) -> dict[str, Any]: """Run a code snippet through the sandbox; this is not a shell replacement.""" - return _gateway().invoke("run_code", _run_code_impl, code, language, timeout, env, approval=approval) + return _dict_result( + _gateway().invoke( + "run_code", _run_code_impl, code, language, timeout, env, approval=approval + ), + tool_name="run_code", + ) def _run_code_impl( @@ -152,7 +199,10 @@ def _run_code_impl( return { "ok": False, "error_type": "isolated_sandbox_required", - "error_message": "run_code requires an isolated sandbox backend; local_process and pod_process are not supported for snippet execution", + "error_message": ( + "run_code requires an isolated sandbox backend; local_process and " + "pod_process are not supported for snippet execution" + ), "backend": backend_name, "boundary": _sandbox_boundary(backend_name), } @@ -181,7 +231,15 @@ def _run_code_impl( "stderr": result.stderr, "exit_code": result.exit_code, } - return budget_text_fields(payload, tool_name="run_code", fields=("stdout", "stderr"), metadata={"tool_use_id": entry.sandbox_id}) + return _dict_result( + budget_text_fields( + payload, + tool_name="run_code", + fields=("stdout", "stderr"), + metadata={"tool_use_id": entry.sandbox_id}, + ), + tool_name="run_code", + ) except SandboxError as exc: return {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} except Exception as exc: @@ -300,10 +358,18 @@ def _int_env(name: str, default: int) -> int: def _sandbox_boundary(backend: str) -> str: if backend in {"local", "local_process"}: - return "executes as a local child process; shares local filesystem, env allowlist, and network" + return ( + "executes as a local child process; shares local filesystem, env allowlist, and network" + ) if backend in {"pod", "pod_process"}: - return "executes inside the agent pod; shares pod filesystem, env allowlist, network, and service account" - return "Sandbox tools execute commands and code only through the configured isolated sandbox backend; they never expose the host shell." + return ( + "executes inside the agent pod; shares pod filesystem, env allowlist, " + "network, and service account" + ) + return ( + "Sandbox tools execute commands and code only through the configured isolated " + "sandbox backend; they never expose the host shell." + ) def get_sandbox_tools() -> list: diff --git a/ksadk/toolsets/skills.py b/ksadk/toolsets/skills.py index 9939357b..923f8715 100644 --- a/ksadk/toolsets/skills.py +++ b/ksadk/toolsets/skills.py @@ -18,15 +18,18 @@ user_skill_space_ids, ) from ksadk.skills.tool_defs import ( - build_execute_skills_tool as build_runtime_execute_skills_tool, + _skill_manifest_item, load_remote_skill_manifests, resolve_skill_space_ids, ) +from ksadk.skills.tool_defs import ( + build_execute_skills_tool as build_runtime_execute_skills_tool, +) from ksadk.tools.gateway import ToolPolicy, default_tool_gateway from ksadk.toolsets._langchain import as_tool - _SKILL_TOOL_POLICIES = { + "list_skill_spaces": ToolPolicy(risk_level="low"), "list_skills": ToolPolicy(risk_level="low"), "search_skills": ToolPolicy(risk_level="low"), "load_skill": ToolPolicy(risk_level="low", side_effects=("skill_cache_write",)), @@ -38,27 +41,132 @@ def _gateway(): return default_tool_gateway(_SKILL_TOOL_POLICIES) -def list_skills() -> dict[str, Any]: - """List skills discoverable from configured Skill Spaces.""" +def _dict_result(value: Any, *, tool_name: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError(f"{tool_name} returned {type(value).__name__}, expected dict") + return dict(value) + +def list_skill_spaces() -> dict[str, Any]: + """List visible Skill Spaces with id/name/description, so the model can pick one by intent. + + 多 space 场景下,先调用本工具了解可选 space,再按用户意图用 + ``search_skills(query, space_id=...)`` 或 ``load_skill(name, space_id=...)`` 定向查找。 + """ + + configured = resolve_skill_space_ids() try: - manifests = load_remote_skill_manifests(resolve_skill_space_ids()) + client = _skill_service_client() + payload = client.list_skill_spaces() + spaces = _parse_skill_spaces(payload) + configured_set = set(configured) + for space in spaces: + space["configured"] = space["space_id"] in configured_set + if not spaces: + # 服务不支持 ListSkillSpaces 时,退化为返回已配置的 space id。 + spaces = [ + {"space_id": sid, "space_name": "", "description": "", "configured": True} + for sid in configured + ] + return { + "ok": True, + "configured_space_ids": configured, + "spaces": spaces, + "usage": ( + "根据用户意图从这些 space 中选一个 space_id,再用 " + "search_skills(query, space_id=...) 在该 space 内搜索,或用 " + "load_skill(skill_name, space_id=...) 精确加载该 space 下的 skill。" + ), + } + except Exception as exc: + return { + "ok": False, + "error_type": type(exc).__name__, + "error_message": str(exc), + "configured_space_ids": configured, + "spaces": [ + {"space_id": sid, "space_name": "", "description": "", "configured": True} + for sid in configured + ], + } + + +def _parse_skill_spaces(payload: dict[str, Any]) -> list[dict[str, Any]]: + data = payload.get("Data") or payload.get("data") or {} + raw = ( + data.get("SkillSpaces") + or data.get("skill_spaces") + or data.get("Spaces") + or data.get("spaces") + or data.get("Items") + or data.get("items") + or [] + ) + spaces: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + continue + spaces.append( + { + "space_id": str( + item.get("SkillSpaceId") + or item.get("skill_space_id") + or item.get("SpaceId") + or item.get("space_id") + or "" + ), + "space_name": str( + item.get("SkillSpaceName") + or item.get("skill_space_name") + or item.get("SpaceName") + or item.get("space_name") + or "" + ), + "description": str(item.get("Description") or item.get("description") or ""), + } + ) + return spaces + + +def list_skills(space_id: str | None = None) -> dict[str, Any]: + """List skills discoverable from configured Skill Spaces, optionally limited to one space. + + ``space_id`` 指定时只列该 space(user space id 或 ``"public"``);缺省列出全部已配置 space。 + """ + + space_ids = [space_id] if space_id else resolve_skill_space_ids() + try: + if space_id: + client = _skill_service_client() + manifests = [ + _skill_manifest_item(skill, space_id=sid) + for sid, skill in _iter_remote_skills(client, space_id=space_id) + if skill.name + ] + else: + manifests = load_remote_skill_manifests(space_ids) return { "ok": True, "skills": manifests, - "skill_space_ids": resolve_skill_space_ids(), + "skill_space_ids": space_ids, + "space_id": space_id, } except Exception as exc: return { "ok": False, "error_type": type(exc).__name__, "error_message": str(exc), - "skill_space_ids": resolve_skill_space_ids(), + "skill_space_ids": space_ids, + "space_id": space_id, } -def load_skill(skill_name: str) -> dict[str, Any]: - """Download and load a skill's SKILL.md instructions from configured Skill Spaces.""" +def load_skill(skill_name: str, space_id: str | None = None) -> dict[str, Any]: + """Download and load a skill's SKILL.md instructions from configured Skill Spaces. + + ``space_id`` 指定时只在该 space 内按名匹配,用于多 space 下精确定位、避免同名冲突; + 缺省按配置顺序(user space 优先于 public)取第一个匹配。 + """ target = str(skill_name or "").strip().lower() if not target: @@ -66,7 +174,7 @@ def load_skill(skill_name: str) -> dict[str, Any]: try: client = _skill_service_client() available: list[str] = [] - for space_id, skill in _iter_remote_skills(client): + for sid, skill in _iter_remote_skills(client, space_id=space_id): if not skill.name: continue available.append(skill.name) @@ -79,7 +187,7 @@ def load_skill(skill_name: str) -> dict[str, Any]: "execution_context": "outer_agent", "name": local_skill.name, "description": local_skill.description, - "space_id": space_id, + "space_id": sid, "skill_id": skill.skill_id, "version_id": skill.version_id, "version": skill.version, @@ -88,31 +196,52 @@ def load_skill(skill_name: str) -> dict[str, Any]: "has_scripts_dir": (local_skill.root_dir / "scripts").is_dir(), "script_files": _script_files(local_skill.root_dir), "instructions": local_skill.body, - "usage": "Read these instructions and complete the user's task in the outer agent unless the skill explicitly requires isolated execution.", + "usage": ( + "Read these instructions and complete the user's task in the outer " + "agent unless the skill explicitly requires isolated execution." + ), } return { "ok": False, "error_message": f"Skill not found: {skill_name}", + "space_id": space_id, "available_skills": available, } except Exception as exc: return {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} -def search_skills(query: str, max_results: int = 10) -> dict[str, Any]: - """Search skills by name, aliases, tags, description, and examples.""" +def search_skills(query: str, max_results: int = 10, space_id: str | None = None) -> dict[str, Any]: + """Search skills by name, aliases, tags, description, and examples. + + ``space_id`` 指定时只在该 space 内搜索;缺省聚合全部已配置 space。结果带 ``space_id``, + 便于后续 ``load_skill(name, space_id=...)`` 精确定位。 + """ query_text = str(query or "").strip() if not query_text: return {"ok": False, "error_message": "query is required"} try: client = _skill_service_client() - refs = [skill for _, skill in _iter_remote_skills(client)] + pairs = list(_iter_remote_skills(client, space_id=space_id)) + # 建立 skill 标识 -> space_id 映射,给结果回填来源 space(cache_key 与 match 去重一致)。 + space_of: dict[str, str] = {} + for sid, skill in pairs: + space_of.setdefault(skill.cache_key or skill.skill_id or skill.name, sid) + refs = [skill for _, skill in pairs] matches = match_skill_refs(refs, query_text, limit=max_results) + results = [] + for match in matches: + item = match.to_dict() + item["space_id"] = space_of.get( + match.skill.cache_key or match.skill.skill_id or match.skill.name, "" + ) + results.append(item) return { "ok": True, "query": query, - "results": [match.to_dict() for match in matches], + "space_id": space_id, + "results": results, } except Exception as exc: return {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} @@ -125,43 +254,64 @@ def execute_skills( ) -> dict[str, Any]: """Execute a workflow through the configured Skill Runtime.""" - return _gateway().invoke( - "execute_skills", - _execute_skills_impl, - workflow_prompt, - skill_names, - approval=approval, + return _dict_result( + _gateway().invoke( + "execute_skills", + _execute_skills_impl, + workflow_prompt, + skill_names, + approval=approval, + ), + tool_name="execute_skills", ) -def _execute_skills_impl(workflow_prompt: str, skill_names: list[str] | str | None = None) -> dict[str, Any]: +def _execute_skills_impl( + workflow_prompt: str, skill_names: list[str] | str | None = None +) -> dict[str, Any]: try: backend = create_skill_runtime_backend() raw_tool = build_runtime_execute_skills_tool(backend=backend) result = raw_tool(workflow_prompt, skill_names) - if isinstance(result, dict): - result.setdefault("execution_context", f"skill-runtime/{_skill_execution_backend()}") - return result + if not isinstance(result, dict): + return { + "ok": False, + "error_type": "invalid_skill_runtime_result", + "error_message": (f"skill runtime returned {type(result).__name__}, expected dict"), + } + result.setdefault("execution_context", f"skill-runtime/{_skill_execution_backend()}") + return dict(result) except SkillRuntimeError as exc: return { "ok": False, "error_type": "skill_runtime_disabled", "error_message": str(exc), - "hint": "Configure KSADK_SKILL_RUNTIME_BACKEND=local_process/e2b or KSADK_SANDBOX_TEMPLATE_ID for isolated Skill workflow execution.", + "hint": ( + "Configure KSADK_SKILL_RUNTIME_BACKEND=local_process/e2b or " + "KSADK_SANDBOX_TEMPLATE_ID for isolated Skill workflow execution." + ), } except Exception as exc: return {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc)} def get_skill_tools() -> list: - return [as_tool(list_skills), as_tool(search_skills), as_tool(load_skill), as_tool(execute_skills)] + return [ + as_tool(list_skills), + as_tool(list_skill_spaces), + as_tool(search_skills), + as_tool(load_skill), + as_tool(execute_skills), + ] def _skill_execution_backend() -> str: explicit = os.environ.get("KSADK_SKILL_RUNTIME_BACKEND", "").strip().lower() if explicit: return explicit - if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID"): + if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get( + "KSADK_SKILL_RUNTIME_TEMPLATE_ID" + ): return "e2b" return "disabled" @@ -177,21 +327,34 @@ def _skill_service_client() -> SkillServiceClient: ) -def _iter_remote_skills(client: SkillServiceClient): - for space_id in user_skill_space_ids(): +def _iter_space_skills(client: SkillServiceClient, space_id: str): + """遍历单个 space 的 active skill;space_id="public" 走 premade 列表。""" + + if space_id == "public": + listing = client.list_available_premade_skills() + for skill in select_public_skill_refs(listing.active_skills()): + yield listing.space_id or "public", skill + else: listing = client.list_skills_by_space_id(space_id) for skill in listing.active_skills(): yield space_id, skill + + +def _iter_remote_skills(client: SkillServiceClient, space_id: str | None = None): + """遍历配置的 space;space_id 指定时只遍历该 space(user 或 "public")。""" + + if space_id: + yield from _iter_space_skills(client, space_id) + return + for user_space_id in user_skill_space_ids(): + yield from _iter_space_skills(client, user_space_id) if public_skill_space_ids(): - listing = client.list_available_premade_skills() - for skill in select_public_skill_refs(listing.active_skills()): - yield listing.space_id or "public", skill + yield from _iter_space_skills(client, "public") def _get_or_download_package(client: SkillServiceClient, skill): cache_dir = Path( - os.environ.get("KSADK_SKILL_CACHE_DIR") - or Path(tempfile.gettempdir()) / "ksadk-skill-cache" + os.environ.get("KSADK_SKILL_CACHE_DIR") or Path(tempfile.gettempdir()) / "ksadk-skill-cache" ) store = PackageStore(cache_dir=cache_dir) package = store.get_cached(skill) @@ -211,7 +374,5 @@ def _script_files(root_dir: Path) -> list[str]: if not scripts_dir.is_dir(): return [] return [ - str(path.relative_to(root_dir)) - for path in sorted(scripts_dir.rglob("*")) - if path.is_file() + str(path.relative_to(root_dir)) for path in sorted(scripts_dir.rglob("*")) if path.is_file() ][:20] diff --git a/ksadk/toolsets/web.py b/ksadk/toolsets/web.py index a0d75820..d97092af 100644 --- a/ksadk/toolsets/web.py +++ b/ksadk/toolsets/web.py @@ -15,10 +15,17 @@ from ksadk.tools.result_budget import budget_tool_output, default_tool_result_budget from ksadk.toolsets._langchain import as_tool - _WEB_TOOL_POLICIES = { - "web_fetch": ToolPolicy(risk_level="low"), - "web_search": ToolPolicy(risk_level="low"), + "web_fetch": ToolPolicy( + risk_level="low", + approval_scopes=("public_network",), + approval_exempt=True, + ), + "web_search": ToolPolicy( + risk_level="low", + approval_scopes=("public_network",), + approval_exempt=True, + ), } @@ -26,10 +33,19 @@ def _gateway(): return default_tool_gateway(_WEB_TOOL_POLICIES) +def _dict_result(value: Any, *, tool_name: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError(f"{tool_name} returned {type(value).__name__}, expected dict") + return dict(value) + + def web_fetch(url: str, max_chars: int | None = None, timeout: int = 30) -> dict: """Fetch a public HTTP(S) URL and return sanitized text content.""" - return _gateway().invoke("web_fetch", _web_fetch_impl, url, max_chars, timeout) + return _dict_result( + _gateway().invoke("web_fetch", _web_fetch_impl, url, max_chars, timeout), + tool_name="web_fetch", + ) def _web_fetch_impl(url: str, max_chars: int | None = None, timeout: int = 30) -> dict: @@ -44,9 +60,19 @@ def _web_fetch_impl(url: str, max_chars: int | None = None, timeout: int = 30) - with httpx.Client(follow_redirects=False, timeout=timeout) as client: response = client.get(current_url) except httpx.TimeoutException: - return {"ok": False, "error_type": "timeout", "error_message": "web_fetch timed out", "url": current_url} + return { + "ok": False, + "error_type": "timeout", + "error_message": "web_fetch timed out", + "url": current_url, + } except httpx.HTTPError as exc: - return {"ok": False, "error_type": type(exc).__name__, "error_message": str(exc), "url": current_url} + return { + "ok": False, + "error_type": type(exc).__name__, + "error_message": str(exc), + "url": current_url, + } if response.is_redirect and response.headers.get("location"): current_url = urljoin(current_url, str(response.headers["location"])) continue @@ -58,7 +84,9 @@ def _web_fetch_impl(url: str, max_chars: int | None = None, timeout: int = 30) - field_name="text", value=text, metadata={"tool_use_id": "web_fetch"}, - budget=default_tool_result_budget(max_chars=limit, preview_chars=limit, persist_threshold_chars=limit), + budget=default_tool_result_budget( + max_chars=limit, preview_chars=limit, persist_threshold_chars=limit + ), ) return { "ok": True, @@ -68,13 +96,21 @@ def _web_fetch_impl(url: str, max_chars: int | None = None, timeout: int = 30) - "content_type": content_type, **budgeted, } - return {"ok": False, "error_type": "too_many_redirects", "error_message": "web_fetch exceeded redirect limit", "url": url} + return { + "ok": False, + "error_type": "too_many_redirects", + "error_message": "web_fetch exceeded redirect limit", + "url": url, + } def web_search(query: str, max_results: int = 5, recency_days: int | None = None) -> dict: """Search the web and return title/url/snippet results without fetching full pages.""" - return _gateway().invoke("web_search", _web_search_impl, query, max_results, recency_days) + return _dict_result( + _gateway().invoke("web_search", _web_search_impl, query, max_results, recency_days), + tool_name="web_search", + ) def _web_search_impl(query: str, max_results: int = 5, recency_days: int | None = None) -> dict: @@ -83,7 +119,11 @@ def _web_search_impl(query: str, max_results: int = 5, recency_days: int | None return {"ok": False, "error_type": "query_required", "error_message": "query is required"} provider = _web_search_provider() if not provider: - return {"ok": False, "error_type": "provider_not_configured", "error_message": "web search provider is not configured"} + return { + "ok": False, + "error_type": "provider_not_configured", + "error_message": "web search provider is not configured", + } if provider == "fake": return { "ok": True, @@ -103,7 +143,11 @@ def _web_search_impl(query: str, max_results: int = 5, recency_days: int | None return _web_search_http(text, max_results=max_results, recency_days=recency_days) if provider == "ksyun": return _web_search_ksyun(text, max_results=max_results) - return {"ok": False, "error_type": "provider_not_configured", "error_message": f"unsupported web search provider: {provider}"} + return { + "ok": False, + "error_type": "provider_not_configured", + "error_message": f"unsupported web search provider: {provider}", + } def get_web_tools() -> list: @@ -112,17 +156,35 @@ def get_web_tools() -> list: def _web_search_provider() -> str: return ( - os.environ.get("KSADK_WEB_SEARCH_PROVIDER") - or os.environ.get("OPENCLAW_WEB_SEARCH_PROVIDER") - or "" - ).strip().lower() + ( + os.environ.get("KSADK_WEB_SEARCH_PROVIDER") + or os.environ.get("OPENCLAW_WEB_SEARCH_PROVIDER") + or "" + ) + .strip() + .lower() + ) -def _web_search_http(query: str, *, max_results: int = 5, recency_days: int | None = None) -> dict[str, Any]: - base_url = str(os.environ.get("KSADK_WEB_SEARCH_BASE_URL") or os.environ.get("OPENCLAW_WEB_SEARCH_BASE_URL") or "").strip() +def _web_search_http( + query: str, *, max_results: int = 5, recency_days: int | None = None +) -> dict[str, Any]: + base_url = str( + os.environ.get("KSADK_WEB_SEARCH_BASE_URL") + or os.environ.get("OPENCLAW_WEB_SEARCH_BASE_URL") + or "" + ).strip() if not base_url: - return {"ok": False, "error_type": "provider_not_configured", "error_message": "web search base URL is not configured"} - api_key = str(os.environ.get("KSADK_WEB_SEARCH_API_KEY") or os.environ.get("OPENCLAW_WEB_SEARCH_API_KEY") or "").strip() + return { + "ok": False, + "error_type": "provider_not_configured", + "error_message": "web search base URL is not configured", + } + api_key = str( + os.environ.get("KSADK_WEB_SEARCH_API_KEY") + or os.environ.get("OPENCLAW_WEB_SEARCH_API_KEY") + or "" + ).strip() params: dict[str, Any] = {"q": query, "max_results": max(1, int(max_results or 5))} if recency_days is not None: params["recency_days"] = int(recency_days) @@ -190,7 +252,14 @@ def _web_search_ksyun(query: str, *, max_results: int = 5) -> dict[str, Any]: endpoint = _ksyun_search_endpoint() api_key = _ksyun_search_api_key() if not api_key: - return {"ok": False, "error_type": "provider_not_configured", "error_message": "ksyun web search api key is not configured (KSADK_WEB_SEARCH_API_KEY / KSADK_MCP_KEY / KSC_AIPRO_API_KEY)"} + return { + "ok": False, + "error_type": "provider_not_configured", + "error_message": ( + "ksyun web search api key is not configured " + "(KSADK_WEB_SEARCH_API_KEY / KSADK_MCP_KEY / KSC_AIPRO_API_KEY)" + ), + } size = max(1, min(int(max_results or 5), 200)) headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"} payload_body = {"q": query, "scope": _ksyun_search_scope(), "size": size} @@ -231,25 +300,53 @@ def _web_search_ksyun(query: str, *, max_results: int = 5) -> dict[str, Any]: def _validate_public_http_url(url: str) -> dict | None: parsed = urlparse(url) if parsed.scheme not in {"http", "https"}: - return {"ok": False, "error_type": "unsupported_scheme", "error_message": "only http and https URLs are supported", "url": url} + return { + "ok": False, + "error_type": "unsupported_scheme", + "error_message": "only http and https URLs are supported", + "url": url, + } if not parsed.hostname: - return {"ok": False, "error_type": "invalid_url", "error_message": "URL host is required", "url": url} + return { + "ok": False, + "error_type": "invalid_url", + "error_message": "URL host is required", + "url": url, + } policy = _ssrf_policy() if policy.get("allow_private") is True: return None try: - addresses = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM) + addresses = socket.getaddrinfo( + parsed.hostname, + parsed.port or (443 if parsed.scheme == "https" else 80), + type=socket.SOCK_STREAM, + ) except socket.gaierror as exc: - return {"ok": False, "error_type": "dns_resolution_failed", "error_message": str(exc), "url": url} + return { + "ok": False, + "error_type": "dns_resolution_failed", + "error_message": str(exc), + "url": url, + } for address in addresses: ip = ipaddress.ip_address(address[4][0]) if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_reserved: - return {"ok": False, "error_type": "blocked_by_ssrf_policy", "error_message": f"blocked non-public address: {ip}", "url": url} + return { + "ok": False, + "error_type": "blocked_by_ssrf_policy", + "error_message": f"blocked non-public address: {ip}", + "url": url, + } return None def _ssrf_policy() -> dict: - raw = os.environ.get("KSADK_WEB_SSRF_POLICY_JSON") or os.environ.get("OPENCLAW_BROWSER_SSRF_POLICY_JSON") or "" + raw = ( + os.environ.get("KSADK_WEB_SSRF_POLICY_JSON") + or os.environ.get("OPENCLAW_BROWSER_SSRF_POLICY_JSON") + or "" + ) if not raw: return {} try: diff --git a/ksadk/toolsets/workspace.py b/ksadk/toolsets/workspace.py index 38f719d1..610d8243 100644 --- a/ksadk/toolsets/workspace.py +++ b/ksadk/toolsets/workspace.py @@ -8,7 +8,7 @@ import shutil import subprocess from pathlib import Path -from typing import Any +from typing import Any, Callable from ksadk.sessions.local_service import resolve_local_session_dir from ksadk.tools.gateway import ToolPolicy, default_tool_gateway @@ -16,7 +16,6 @@ from ksadk.toolsets._langchain import as_tool from ksadk.toolsets.workspace_state import WorkspaceReadState, get_read_state, record_read_state - _WORKSPACE_TOOL_POLICIES = { "workspace_status": ToolPolicy(risk_level="low"), "list_workspace_files": ToolPolicy(risk_level="low"), @@ -35,8 +34,14 @@ def _gateway(): return default_tool_gateway(_WORKSPACE_TOOL_POLICIES) +def _dict_result(value: Any, *, tool_name: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError(f"{tool_name} returned {type(value).__name__}, expected dict") + return dict(value) + + def workspace_root() -> Path: - return resolve_local_session_dir() / "workspace" + return Path(resolve_local_session_dir()) / "workspace" def resolve_workspace_path(relative_path: str) -> Path: @@ -55,7 +60,10 @@ def workspace_relative(path: Path) -> str: def workspace_status() -> dict[str, Any]: """Return current AgentEngine workspace status.""" - return _gateway().invoke("workspace_status", _workspace_status_impl) + return _dict_result( + _gateway().invoke("workspace_status", _workspace_status_impl), + tool_name="workspace_status", + ) def _workspace_status_impl() -> dict[str, Any]: @@ -83,15 +91,18 @@ def list_workspace_files( ) -> dict[str, Any]: """List files under the AgentEngine workspace.""" - return _gateway().invoke( - "list_workspace_files", - _list_workspace_files_impl, - path, - glob, - recursive, - include_dirs, - max_results, - sort_by, + return _dict_result( + _gateway().invoke( + "list_workspace_files", + _list_workspace_files_impl, + path, + glob, + recursive, + include_dirs, + max_results, + sort_by, + ), + tool_name="list_workspace_files", ) @@ -117,17 +128,31 @@ def _list_workspace_files_impl( items = [ item for item in items - if fnmatch.fnmatch(workspace_relative(item), pattern) or fnmatch.fnmatch(item.name, pattern) + if fnmatch.fnmatch(workspace_relative(item), pattern) + or fnmatch.fnmatch(item.name, pattern) ] if not include_dirs: items = [item for item in items if item.is_file()] sort_key = str(sort_by or "name").strip().lower() + key: Callable[[Path], tuple[Any, ...]] if sort_key == "mtime": - key = lambda candidate: (candidate.stat().st_mtime_ns, workspace_relative(candidate)) + + def key(candidate: Path) -> tuple[Any, ...]: + return candidate.stat().st_mtime_ns, workspace_relative(candidate) + elif sort_key == "size": - key = lambda candidate: (candidate.stat().st_size if candidate.is_file() else 0, workspace_relative(candidate)) + + def key(candidate: Path) -> tuple[Any, ...]: + return ( + candidate.stat().st_size if candidate.is_file() else 0, + workspace_relative(candidate), + ) + else: - key = lambda candidate: (workspace_relative(candidate).lower(), candidate.is_file()) + + def key(candidate: Path) -> tuple[Any, ...]: + return workspace_relative(candidate).lower(), candidate.is_file() + limit = max(1, min(int(max_results or 500), 5000)) sorted_items = sorted(items, key=key) entries = [ @@ -140,7 +165,13 @@ def _list_workspace_files_impl( } for item in sorted_items[:limit] ] - return {"ok": True, "path": path, "recursive": recursive, "entries": entries, "truncated": len(sorted_items) > len(entries)} + return { + "ok": True, + "path": path, + "recursive": recursive, + "entries": entries, + "truncated": len(sorted_items) > len(entries), + } def read_workspace_file( @@ -155,14 +186,17 @@ def read_workspace_file( Do not issue a dependent read and edit in the same parallel tool-call batch. """ - return _gateway().invoke( - "read_workspace_file", - _read_workspace_file_impl, - path, - start_line, - end_line, - max_chars, - include_line_numbers, + return _dict_result( + _gateway().invoke( + "read_workspace_file", + _read_workspace_file_impl, + path, + start_line, + end_line, + max_chars, + include_line_numbers, + ), + tool_name="read_workspace_file", ) @@ -201,7 +235,9 @@ def _read_workspace_file_impl( field_name="content", value=content, metadata={"tool_use_id": f"read_{relative.replace('/', '_')}"}, - budget=default_tool_result_budget(max_chars=limit, preview_chars=limit, persist_threshold_chars=limit), + budget=default_tool_result_budget( + max_chars=limit, preview_chars=limit, persist_threshold_chars=limit + ), ) partial = start != 1 or end != total_lines record_read_state( @@ -251,13 +287,16 @@ def write_workspace_file( ) -> dict[str, Any]: """Write a UTF-8 text file inside the AgentEngine workspace.""" - return _gateway().invoke( - "write_workspace_file", - _write_workspace_file_impl, - path, - content, - overwrite, - approval=approval, + return _dict_result( + _gateway().invoke( + "write_workspace_file", + _write_workspace_file_impl, + path, + content, + overwrite, + approval=approval, + ), + tool_name="write_workspace_file", ) @@ -282,16 +321,21 @@ def write_workspace_files( ) -> dict[str, Any]: """Write multiple UTF-8 text files inside the AgentEngine workspace.""" - return _gateway().invoke( - "write_workspace_files", - _write_workspace_files_impl, - files, - overwrite, - approval=approval, + return _dict_result( + _gateway().invoke( + "write_workspace_files", + _write_workspace_files_impl, + files, + overwrite, + approval=approval, + ), + tool_name="write_workspace_files", ) -def _write_workspace_files_impl(files: list[dict[str, Any]], overwrite: bool = True) -> dict[str, Any]: +def _write_workspace_files_impl( + files: list[dict[str, Any]], overwrite: bool = True +) -> dict[str, Any]: if not isinstance(files, list) or not files: return {"ok": False, "error_message": "files must be a non-empty list"} written = [] @@ -301,7 +345,9 @@ def _write_workspace_files_impl(files: list[dict[str, Any]], overwrite: bool = T path = str(item.get("path") or "").strip() if not path: return {"ok": False, "error_message": "each file item requires path"} - result = _write_workspace_file_impl(path, str(item.get("content") or ""), overwrite=overwrite) + result = _write_workspace_file_impl( + path, str(item.get("content") or ""), overwrite=overwrite + ) if not result.get("ok"): return result written.append({"path": result["path"], "size": result["size"]}) @@ -321,15 +367,18 @@ def edit_workspace_file( The read must complete before this call; do not batch a dependent read and edit. """ - return _gateway().invoke( - "edit_workspace_file", - _edit_workspace_file_impl, - path, - old_text, - new_text, - expected_replacements, - replace_all, - approval=approval, + return _dict_result( + _gateway().invoke( + "edit_workspace_file", + _edit_workspace_file_impl, + path, + old_text, + new_text, + expected_replacements, + replace_all, + approval=approval, + ), + tool_name="edit_workspace_file", ) @@ -351,6 +400,8 @@ def _edit_workspace_file_impl( text, error = _read_workspace_text(target) if error is not None: return {**error, "path": path} + if text is None: + return {"ok": False, "path": path, "error_message": "workspace file read failed"} edit_result = _apply_single_edit( text, old_text, @@ -390,13 +441,16 @@ def multi_edit_workspace_file( The read must complete before this call; do not batch a dependent read and edit. """ - return _gateway().invoke( - "multi_edit_workspace_file", - _multi_edit_workspace_file_impl, - path, - edits, - replace_all, - approval=approval, + return _dict_result( + _gateway().invoke( + "multi_edit_workspace_file", + _multi_edit_workspace_file_impl, + path, + edits, + replace_all, + approval=approval, + ), + tool_name="multi_edit_workspace_file", ) @@ -478,7 +532,10 @@ def _multi_edit_workspace_file_impl( def lint_workspace_file(path: str, language: str = "auto") -> dict[str, Any]: """Run lightweight built-in lint checks for a UTF-8 workspace text file.""" - return _gateway().invoke("lint_workspace_file", _lint_workspace_file_impl, path, language) + return _dict_result( + _gateway().invoke("lint_workspace_file", _lint_workspace_file_impl, path, language), + tool_name="lint_workspace_file", + ) def _lint_workspace_file_impl(path: str, language: str = "auto") -> dict[str, Any]: @@ -515,9 +572,23 @@ def _lint_workspace_file_impl(path: str, language: str = "auto") -> dict[str, An else: for line_no, line in enumerate((text or "").splitlines(), start=1): if "\x00" in line: - issues.append({"severity": "error", "line": line_no, "column": line.index("\x00") + 1, "message": "NUL byte found"}) + issues.append( + { + "severity": "error", + "line": line_no, + "column": line.index("\x00") + 1, + "message": "NUL byte found", + } + ) if line.rstrip("\n\r") != line.rstrip(): - issues.append({"severity": "warning", "line": line_no, "column": len(line), "message": "trailing whitespace"}) + issues.append( + { + "severity": "warning", + "line": line_no, + "column": len(line), + "message": "trailing whitespace", + } + ) if len(issues) >= 20: break return { @@ -557,16 +628,19 @@ def search_workspace_files( ) -> dict[str, Any]: """Search UTF-8 text files in the AgentEngine workspace.""" - return _gateway().invoke( - "search_workspace_files", - _search_workspace_files_impl, - query, - path, - is_regex, - glob, - case_sensitive, - context_lines, - max_results, + return _dict_result( + _gateway().invoke( + "search_workspace_files", + _search_workspace_files_impl, + query, + path, + is_regex, + glob, + case_sensitive, + context_lines, + max_results, + ), + tool_name="search_workspace_files", ) @@ -584,15 +658,23 @@ def _search_workspace_files_impl( return {"ok": False, "error_message": "query is required"} base = resolve_workspace_path(path) if shutil.which("rg") and not context_lines: - rg_result = _search_with_rg(needle, base, is_regex, glob, case_sensitive, context_lines, max_results) + rg_result = _search_with_rg( + needle, base, is_regex, glob, case_sensitive, context_lines, max_results + ) if rg_result is not None: - return _search_payload(query, path, rg_result["results"], rg_result["truncated"], context_lines, "rg") + return _search_payload( + query, path, rg_result["results"], rg_result["truncated"], context_lines, "rg" + ) candidates = [base] if base.is_file() else [item for item in base.rglob("*") if item.is_file()] if glob: - candidates = [item for item in candidates if fnmatch.fnmatch(workspace_relative(item), glob) or fnmatch.fnmatch(item.name, glob)] + candidates = [ + item + for item in candidates + if fnmatch.fnmatch(workspace_relative(item), glob) or fnmatch.fnmatch(item.name, glob) + ] flags = 0 if case_sensitive else re.IGNORECASE pattern = re.compile(needle if is_regex else re.escape(needle), flags) - results = [] + results: list[dict[str, Any]] = [] limit = max(1, min(int(max_results or 100), 5000)) context_limit = max(0, min(int(context_lines or 0), 20)) truncated = False @@ -665,7 +747,7 @@ def _search_with_rg( return None if completed.returncode not in {0, 1}: return None - results = [] + results: list[dict[str, Any]] = [] limit = max(1, min(int(max_results or 100), 5000)) truncated = False for raw_line in completed.stdout.splitlines(): @@ -725,13 +807,19 @@ def _validate_read_state(target: Path, relative: str) -> dict[str, Any] | None: "suggested_action": f"Call read_workspace_file(path={relative!r}) before editing.", } stat = target.stat() if target.exists() else None - if stat is None or stat.st_mtime_ns != read_state.mtime_ns or stat.st_size != read_state.size_bytes: + if ( + stat is None + or stat.st_mtime_ns != read_state.mtime_ns + or stat.st_size != read_state.size_bytes + ): return { "ok": False, "path": relative, "error_type": "file_modified_since_read", "error_message": "workspace file changed since last read", - "suggested_action": f"Re-read the file with read_workspace_file(path={relative!r}) and retry.", + "suggested_action": ( + f"Re-read the file with read_workspace_file(path={relative!r}) " "and retry." + ), } return None @@ -761,7 +849,17 @@ def _apply_single_edit( "error_message": "old_text was not found in the workspace file", "occurrences": 0, "nearby_candidates": _nearby_candidates(text, old), - "suggested_action": "Use one nearby candidate as old_text, or call read_workspace_file with line numbers and retry.", + "suggested_action": ( + "Use one nearby candidate as old_text, or call read_workspace_file " + "with line numbers and retry." + ), + } + if actual_old is None: + return { + "ok": False, + "path": relative, + "error_type": "snippet_not_found", + "error_message": "old_text was not found in the workspace file", } if not replace_all and occurrences != expected: return { @@ -772,7 +870,10 @@ def _apply_single_edit( "occurrences": occurrences, "expected_replacements": expected, "matches": matches, - "suggested_action": "Provide a larger unique old_text snippet, set expected_replacements, or use replace_all=true.", + "suggested_action": ( + "Provide a larger unique old_text snippet, set expected_replacements, " + "or use replace_all=true." + ), } replacements = occurrences if replace_all else expected return { @@ -809,11 +910,15 @@ def _match_previews(text: str, needle: str, limit: int = 20) -> list[dict[str, A def _nearby_candidates(text: str, old: str, limit: int = 5) -> list[dict[str, Any]]: old_norm = _normalize_quotes(old).strip() - candidates = [] + candidates: list[dict[str, Any]] = [] for line_no, line in enumerate(text.splitlines(), start=1): ratio = difflib.SequenceMatcher(None, old_norm, _normalize_quotes(line).strip()).ratio() - if old_norm and (old_norm[: max(1, min(4, len(old_norm)))] in _normalize_quotes(line) or ratio > 0.45): - candidates.append({"line": line_no, "text": line.strip(), "snippet": line, "score": round(ratio, 3)}) + if old_norm and ( + old_norm[: max(1, min(4, len(old_norm)))] in _normalize_quotes(line) or ratio > 0.45 + ): + candidates.append( + {"line": line_no, "text": line.strip(), "snippet": line, "score": round(ratio, 3)} + ) candidates.sort(key=lambda item: (-float(item["score"]), int(item["line"]))) return candidates[:limit] @@ -830,11 +935,14 @@ def _build_diff(relative: str, before: str, after: str) -> str: def _budget_diff(tool_name: str, diff: str, relative: str) -> dict[str, Any]: - return budget_tool_output( + return _dict_result( + budget_tool_output( + tool_name=tool_name, + field_name="diff", + value=diff, + metadata={"tool_use_id": f"{tool_name}_{relative.replace('/', '_')}"}, + ), tool_name=tool_name, - field_name="diff", - value=diff, - metadata={"tool_use_id": f"{tool_name}_{relative.replace('/', '_')}"}, ) @@ -857,7 +965,8 @@ def _find_actual_string(text: str, old: str) -> tuple[str | None, bool]: if old in text: return old, False normalized_old = _normalize_quotes(old) - for candidate in {normalized_old, old.translate(str.maketrans({'"': "“", "'": "‘"}))}: + smart_quote_map: dict[str, str | int | None] = {'"': "“", "'": "‘"} + for candidate in {normalized_old, old.translate(str.maketrans(smart_quote_map))}: if candidate and candidate in text: return candidate, True normalized_text = _normalize_quotes(text) @@ -868,24 +977,26 @@ def _find_actual_string(text: str, old: str) -> tuple[str | None, bool]: def _normalize_quotes(value: str) -> str: - return value.translate( - str.maketrans( - { - "“": '"', - "”": '"', - "„": '"', - "‘": "'", - "’": "'", - "‚": "'", - } - ) - ) + quote_map: dict[str, str | int | None] = { + "“": '"', + "”": '"', + "„": '"', + "‘": "'", + "’": "'", + "‚": "'", + } + return value.translate(str.maketrans(quote_map)) def delete_workspace_file(path: str, approval: dict[str, Any] | None = None) -> dict[str, Any]: """Delete a file or empty directory inside the AgentEngine workspace.""" - return _gateway().invoke("delete_workspace_file", _delete_workspace_file_impl, path, approval=approval) + return _dict_result( + _gateway().invoke( + "delete_workspace_file", _delete_workspace_file_impl, path, approval=approval + ), + tool_name="delete_workspace_file", + ) def _delete_workspace_file_impl(path: str) -> dict[str, Any]: diff --git a/ksadk/tracing/exporters/inmemory_exporter.py b/ksadk/tracing/exporters/inmemory_exporter.py index 2b277913..0927ea74 100644 --- a/ksadk/tracing/exporters/inmemory_exporter.py +++ b/ksadk/tracing/exporters/inmemory_exporter.py @@ -4,112 +4,123 @@ 用于本地 Web UI 查看 Traces """ -from typing import List, Dict, Any, Optional -from collections import deque import threading -import time +from collections import deque +from collections.abc import Sequence +from typing import Any + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult -class InMemoryExporter: +class InMemoryExporter(SpanExporter): """内存 Trace Exporter""" - + def __init__(self, max_traces: int = 1000): self.max_traces = max_traces - self._traces: deque = deque(maxlen=max_traces) + self._traces: deque[dict[str, Any]] = deque(maxlen=max_traces) self._lock = threading.Lock() - - def export(self, spans) -> int: + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: """导出 Spans (符合 OpenTelemetry SpanExporter 接口)""" - from opentelemetry.sdk.trace.export import SpanExportResult - with self._lock: for span in spans: trace_data = self._span_to_dict(span) self._traces.append(trace_data) - + return SpanExportResult.SUCCESS - + def shutdown(self) -> None: """关闭 Exporter""" pass - + def force_flush(self, timeout_millis: int = 30000) -> bool: """强制刷新""" return True - - def _span_to_dict(self, span) -> Dict[str, Any]: + + def _span_to_dict(self, span: ReadableSpan) -> dict[str, Any]: """将 Span 转换为字典""" ctx = span.get_span_context() - + trace_id = ctx.trace_id if ctx is not None else 0 + span_id = ctx.span_id if ctx is not None else 0 + start_time = span.start_time or 0 + end_time = span.end_time or 0 + return { - "trace_id": format(ctx.trace_id, '032x'), - "span_id": format(ctx.span_id, '016x'), - "parent_span_id": format(span.parent.span_id, '016x') if span.parent else None, + "trace_id": format(trace_id, "032x"), + "span_id": format(span_id, "016x"), + "parent_span_id": format(span.parent.span_id, "016x") if span.parent else None, "name": span.name, "kind": str(span.kind), "start_time": span.start_time, "end_time": span.end_time, - "duration_ns": span.end_time - span.start_time if span.end_time else 0, + "duration_ns": end_time - start_time if end_time else 0, "attributes": dict(span.attributes) if span.attributes else {}, "status": { "code": str(span.status.status_code), - "description": span.status.description + "description": span.status.description, }, - "events": [ - { - "name": event.name, - "timestamp": event.timestamp, - "attributes": dict(event.attributes) if event.attributes else {} - } - for event in span.events - ] if span.events else [] + "events": ( + [ + { + "name": event.name, + "timestamp": event.timestamp, + "attributes": dict(event.attributes) if event.attributes else {}, + } + for event in span.events + ] + if span.events + else [] + ), } - - def get_traces(self, limit: int = 100) -> List[Dict[str, Any]]: + + def get_traces(self, limit: int = 100) -> list[dict[str, Any]]: """获取 Traces""" with self._lock: traces = list(self._traces)[-limit:] - + # 按 trace_id 分组 - grouped: Dict[str, List[Dict]] = {} + grouped: dict[str, list[dict[str, Any]]] = {} for span in traces: trace_id = span["trace_id"] if trace_id not in grouped: grouped[trace_id] = [] grouped[trace_id].append(span) - + # 转换为 trace 列表 - result = [] + result: list[dict[str, Any]] = [] for trace_id, spans in grouped.items(): - result.append({ - "trace_id": trace_id, - "spans": sorted(spans, key=lambda x: x["start_time"]), - "span_count": len(spans), - "start_time": min(s["start_time"] for s in spans), - "end_time": max(s["end_time"] for s in spans if s["end_time"]), - }) - + result.append( + { + "trace_id": trace_id, + "spans": sorted(spans, key=lambda x: x["start_time"]), + "span_count": len(spans), + "start_time": min(s["start_time"] for s in spans), + "end_time": max(s["end_time"] for s in spans if s["end_time"]), + } + ) + return sorted(result, key=lambda x: x["start_time"], reverse=True)[:limit] - - def get_trace(self, trace_id: str) -> Optional[Dict[str, Any]]: + + def get_trace(self, trace_id: str) -> dict[str, Any] | None: """获取单个 Trace""" with self._lock: spans = [s for s in self._traces if s["trace_id"] == trace_id] - + if not spans: return None - + return { "trace_id": trace_id, "spans": sorted(spans, key=lambda x: x["start_time"]), - "span_count": len(spans) + "span_count": len(spans), } - - def get_finished_spans(self) -> List[Dict[str, Any]]: + + def get_finished_spans(self) -> list[dict[str, Any]]: """返回所有已完成的 Spans (兼容 OpenTelemetry InMemorySpanExporter 接口)""" with self._lock: return list(self._traces) - + def clear(self) -> None: """清空 Traces""" with self._lock: diff --git a/ksadk/tracing/exporters/langfuse_exporter.py b/ksadk/tracing/exporters/langfuse_exporter.py index 97a62fe6..a8551741 100644 --- a/ksadk/tracing/exporters/langfuse_exporter.py +++ b/ksadk/tracing/exporters/langfuse_exporter.py @@ -8,8 +8,6 @@ import json import logging import os -from datetime import datetime -from typing import Sequence, Optional from dataclasses import dataclass logger = logging.getLogger(__name__) @@ -18,6 +16,7 @@ @dataclass class LangfuseExporterConfig: """Configuration for Langfuse exporter""" + public_key: str secret_key: str host: str = "http://localhost:3000" @@ -30,24 +29,25 @@ class LangfuseExporterConfig: class _LangfuseSpanExporter: """Internal span exporter that sends spans to Langfuse. - + Uses Langfuse low-level SDK API (v3 compatible). Agent metadata is loaded from ksadk.configs.settings. """ - + def __init__(self, config: LangfuseExporterConfig): self.config = config self._langfuse = None self._agent_config = None # Lazy load self._init_langfuse() - + def _get_agent_config(self): """Lazy load agent config from unified settings""" if self._agent_config is None: try: from ksadk.configs import settings + self._agent_config = settings.agent - + # Log loaded metadata if self._agent_config.agent_id: logger.info(f"Agent metadata loaded: agent_id={self._agent_config.agent_id}") @@ -57,7 +57,7 @@ def _get_agent_config(self): logger.warning("ksadk.configs not available, agent metadata disabled") self._agent_config = None return self._agent_config - + def _init_langfuse(self): """Initialize Langfuse client using low-level API""" try: @@ -65,13 +65,14 @@ def _init_langfuse(self): os.environ["LANGFUSE_PUBLIC_KEY"] = self.config.public_key os.environ["LANGFUSE_SECRET_KEY"] = self.config.secret_key os.environ["LANGFUSE_HOST"] = self.config.host - + # Use Langfuse low-level client from langfuse import Langfuse + self._langfuse = Langfuse( public_key=self.config.public_key, secret_key=self.config.secret_key, - host=self.config.host + host=self.config.host, ) logger.info(f"Langfuse client initialized: {self.config.host}") except ImportError: @@ -80,43 +81,48 @@ def _init_langfuse(self): except Exception as e: logger.error(f"Failed to initialize Langfuse: {e}") self._langfuse = None - + def export(self, spans) -> int: """Export spans to Langfuse using low-level API""" if not self._langfuse: return 0 - + from opentelemetry.sdk.trace.export import SpanExportResult - + try: # Group spans by trace_id traces = {} for span in spans: - trace_id = format(span.context.trace_id, '032x') + trace_id = format(span.context.trace_id, "032x") if trace_id not in traces: traces[trace_id] = [] traces[trace_id].append(span) - + # Process each trace group for trace_id, trace_spans in traces.items(): self._export_trace(trace_id, trace_spans) - + self._langfuse.flush() return SpanExportResult.SUCCESS - + except Exception as e: logger.error(f"Failed to export to Langfuse: {e}") import traceback + traceback.print_exc() return SpanExportResult.FAILURE - + def _export_trace(self, trace_id: str, spans): """Export spans using Langfuse ingestion API""" + langfuse = self._langfuse + if langfuse is None: + return + # Find root span and categorize root_span = None llm_spans = [] tool_spans = [] - + for span in spans: # Robust Root Span Detection: # 1. Check if parent is None (standard OTEL way) @@ -125,41 +131,49 @@ def _export_trace(self, trace_id: str, spans): if hasattr(span, "parent") and span.parent: # Local parent exists is_root = False - elif hasattr(span, "context") and hasattr(span.context, "is_remote") and span.context.is_remote: - # Remote parent (distributed tracing) - treat as root for this service context? - # For now, we treat standard root spans (no parent) as root. - pass + elif ( + hasattr(span, "context") + and hasattr(span.context, "is_remote") + and span.context.is_remote + ): + # Remote parent (distributed tracing) - treat as root for this service context? + # For now, we treat standard root spans (no parent) as root. + pass elif not span.parent: is_root = True - + # Fallback for SDKs that might not expose parent property easily or if it's None if not is_root: name = span.name - if name.startswith("langgraph.") or name.startswith("langchain.") or name.startswith("adk."): + if ( + name.startswith("langgraph.") + or name.startswith("langchain.") + or name.startswith("adk.") + ): is_root = True - + if is_root: root_span = span elif name == "call_llm": llm_spans.append(span) elif name.startswith("tool."): tool_spans.append(span) - + if not root_span: # If batch doesn't contain a clear root, use the first span to carry context if needed # But be careful not to treat random child spans as root for attributes root_span = spans[0] if spans else None - + if not root_span: return - + # Extract attributes attrs = dict(root_span.attributes) if root_span.attributes else {} - # Use OTEL Trace ID as Langfuse Trace ID to ensure consistency between manual and auto-instrumented spans - invocation_id = trace_id + # Preserve the OTEL trace ID across manual and auto-instrumented spans. + invocation_id = trace_id user_input = attrs.get("user.input", "") agent_output = attrs.get("agent.output", "") - + # Extract Langfuse-specific attributes from span span_session_id = ( attrs.get("langfuse.session.id") @@ -168,11 +182,9 @@ def _export_trace(self, trace_id: str, spans): ) span_tags = attrs.get("langfuse.tags", "") span_user_id = ( - attrs.get("langfuse.user.id") - or attrs.get("langfuse.user_id") - or attrs.get("user.id") + attrs.get("langfuse.user.id") or attrs.get("langfuse.user_id") or attrs.get("user.id") ) - + # Create trace using ingestion endpoint try: # Build base metadata @@ -180,31 +192,33 @@ def _export_trace(self, trace_id: str, spans): "trace_id": trace_id, "framework": "ksadk", } - + # Get agent config from unified settings agent_config = self._get_agent_config() - + # Merge with agent metadata if available langfuse_params = {} trace_name = root_span.name - + if agent_config: agent_metadata = agent_config.to_langfuse_metadata() base_metadata.update(agent_metadata) - + langfuse_params = agent_config.to_langfuse_params() - + # Use agent_name if available (only if root span) - if agent_config.agent_name and root_span == spans[0]: # Rough check if it's main Span + if ( + agent_config.agent_name and root_span == spans[0] + ): # Rough check if it's main Span trace_name = agent_config.agent_name - + # 优先使用 span attributes 中的值 (从 runner 传递) if span_session_id: langfuse_params["session_id"] = span_session_id - + if span_user_id: langfuse_params["user_id"] = span_user_id - + # 处理 tags existing_tags = langfuse_params.get("tags", []) or [] if span_tags: @@ -212,13 +226,13 @@ def _export_trace(self, trace_id: str, spans): for tag in span_tags_list: if tag not in existing_tags: existing_tags.append(tag) - + if agent_config: - if agent_config.agent_name and agent_config.agent_name not in existing_tags: + if agent_config.agent_name and agent_config.agent_name not in existing_tags: existing_tags.append(agent_config.agent_name) - if agent_config.environment and agent_config.environment not in existing_tags: + if agent_config.environment and agent_config.environment not in existing_tags: existing_tags.append(agent_config.environment) - + if existing_tags: langfuse_params["tags"] = existing_tags @@ -227,21 +241,21 @@ def _export_trace(self, trace_id: str, spans): "id": invocation_id, "name": trace_name, "metadata": base_metadata, - **langfuse_params + **langfuse_params, } - + if user_input: trace_kwargs["input"] = {"text": user_input} if agent_output: trace_kwargs["output"] = {"text": agent_output} - + # Use create_trace method (available in v3) - trace = self._langfuse.trace(**trace_kwargs) - + trace = langfuse.trace(**trace_kwargs) + # Add LLM generations for llm_span in llm_spans: self._add_generation(trace, llm_span) - + # Add tool spans for tool_span in tool_spans: self._add_tool_span(trace, tool_span) @@ -251,32 +265,32 @@ def _export_trace(self, trace_id: str, spans): except AttributeError: # Fallback: use score/event API if trace() not available self._export_via_events(invocation_id, root_span, llm_spans, tool_spans) - + def _add_generation(self, trace, span): """Add LLM generation to trace""" attrs = dict(span.attributes) if span.attributes else {} - + llm_request_str = attrs.get("gcp.vertex.agent.llm_request", "{}") llm_response_str = attrs.get("gcp.vertex.agent.llm_response", "{}") - + try: llm_request = json.loads(llm_request_str) llm_response = json.loads(llm_response_str) except json.JSONDecodeError: llm_request = {"raw": llm_request_str} llm_response = {"raw": llm_response_str} - + # Extract text content input_text = "" output_text = "" - + if "contents" in llm_request: contents = llm_request.get("contents", []) if contents and "parts" in contents[0]: parts = contents[0].get("parts", []) if parts and "text" in parts[0]: input_text = parts[0]["text"] - + if "candidates" in llm_response: candidates = llm_response.get("candidates", []) if candidates and "content" in candidates[0]: @@ -285,9 +299,9 @@ def _add_generation(self, trace, span): parts = content["parts"] if parts and "text" in parts[0]: output_text = parts[0]["text"] - + model = attrs.get("model", attrs.get("gen_ai.request.model", "unknown")) - + try: trace.generation( name=span.name, @@ -295,21 +309,21 @@ def _add_generation(self, trace, span): input=input_text or llm_request, output=output_text or llm_response, metadata={ - "span_id": format(span.context.span_id, '016x'), - } + "span_id": format(span.context.span_id, "016x"), + }, ) except AttributeError: # Generation method not available, skip pass - + def _add_tool_span(self, trace, span): """Add tool execution span""" attrs = dict(span.attributes) if span.attributes else {} - + tool_name = attrs.get("tool.name", span.name.replace("tool.", "")) tool_input = attrs.get("tool.input", "") tool_output = attrs.get("tool.output", "") - + try: trace.span( name=f"tool:{tool_name}", @@ -317,7 +331,7 @@ def _add_tool_span(self, trace, span): output={"result": tool_output} if tool_output else None, metadata={ "tool_name": tool_name, - } + }, ) except AttributeError: pass @@ -387,14 +401,14 @@ def _add_score(self, trace_id: str, attrs: dict) -> None: self._langfuse.score(**score_kwargs) except AttributeError: pass - + def _export_via_events(self, trace_id: str, root_span, llm_spans, tool_spans): """Fallback export using event-based API""" # This is a fallback for when trace() method is not available logger.debug(f"Exporting trace {trace_id} via fallback method") # Just log for now - can be enhanced later pass - + def shutdown(self): """Shutdown the exporter""" if self._langfuse: @@ -403,7 +417,7 @@ def shutdown(self): self._langfuse.shutdown() except Exception: pass - + def force_flush(self, timeout_millis: int = 30000) -> bool: """Force flush pending spans""" if self._langfuse: @@ -416,26 +430,27 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: class LangfuseExporter: """Langfuse exporter for KsADK tracing system. - + Compatible with Langfuse SDK v3. """ - + def __init__(self, config: LangfuseExporterConfig): self.config = config self.name = "langfuse_exporter" - + self._exporter = _LangfuseSpanExporter(config) self.processor = self._create_processor() - + def _create_processor(self): """Create span processor for this exporter""" try: from opentelemetry.sdk.trace.export import SimpleSpanProcessor + return SimpleSpanProcessor(self._exporter) except ImportError: logger.error("OpenTelemetry SDK not installed") return None - + @property def resource_attributes(self) -> dict: """Return resource attributes for this exporter""" diff --git a/ksadk/tracing/setup.py b/ksadk/tracing/setup.py index 34a9c816..efc995b2 100644 --- a/ksadk/tracing/setup.py +++ b/ksadk/tracing/setup.py @@ -5,12 +5,17 @@ import atexit import base64 +import importlib import logging import os +from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Any, Optional from urllib.parse import unquote +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + from ksadk.tracing.exporters.inmemory_exporter import InMemoryExporter logger = logging.getLogger(__name__) @@ -49,18 +54,18 @@ class _OtlpHttpConfig: service_name: str -class _LoggingSpanExporter: +class _LoggingSpanExporter(SpanExporter): """Small delegating exporter that logs external trace export flow.""" def __init__( self, - exporter: Any, + exporter: SpanExporter, *, name: str, endpoint: str, service_name: str, header_keys: list[str], - span_transform: Any = None, + span_transform: Callable[[Sequence[ReadableSpan]], Sequence[ReadableSpan]] | None = None, ): self._exporter = exporter self._name = name @@ -69,8 +74,8 @@ def __init__( self._header_keys = header_keys self._span_transform = span_transform - def export(self, spans): - span_count = len(spans) if hasattr(spans, "__len__") else "unknown" + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + span_count = len(spans) logger.info( "%s export started: endpoint=%s service_name=%s spans=%s headers=%s", self._name, @@ -101,24 +106,18 @@ def export(self, spans): ) return result - def shutdown(self): - shutdown = getattr(self._exporter, "shutdown", None) - if shutdown is None: - return None + def shutdown(self) -> None: logger.info("%s exporter shutdown: endpoint=%s", self._name, self._endpoint) - return shutdown() + self._exporter.shutdown() def force_flush(self, timeout_millis: int = 30000) -> bool: - force_flush = getattr(self._exporter, "force_flush", None) - if force_flush is None: - return True logger.info( "%s exporter force_flush: endpoint=%s timeout_millis=%s", self._name, self._endpoint, timeout_millis, ) - return force_flush(timeout_millis) + return self._exporter.force_flush(timeout_millis) def _coerce_int(value: Any) -> int: @@ -164,9 +163,7 @@ def _span_token_usage(span: Any) -> dict[str, int]: "input": _coerce_int(attributes.get("gen_ai.usage.input_tokens")), "output": _coerce_int(attributes.get("gen_ai.usage.output_tokens")), "cache_read": _coerce_int(attributes.get("gen_ai.usage.cache_read.input_tokens")), - "reasoning_output": _coerce_int( - attributes.get("gen_ai.usage.reasoning.output_tokens") - ), + "reasoning_output": _coerce_int(attributes.get("gen_ai.usage.reasoning.output_tokens")), } @@ -294,13 +291,16 @@ def _prepare_cloud_monitor_spans(spans: Any) -> Any: def has_token_descendant(span: Any) -> bool: sid = _span_id(span) - key = (_trace_id(span), sid) if sid is None: return False + key = (_trace_id(span), sid) if key in has_token_descendant_cache: return has_token_descendant_cache[key] for child in children_by_parent.get(key, []): - child_key = (_trace_id(child), _span_id(child)) + child_sid = _span_id(child) + if child_sid is None: + continue + child_key = (_trace_id(child), child_sid) if _has_nonzero_token_usage(token_usage_by_id.get(child_key, {})): has_token_descendant_cache[key] = True return True @@ -312,6 +312,8 @@ def has_token_descendant(span: Any) -> bool: def collect_leaf_usage(span: Any, usage: dict[str, int]) -> None: sid = _span_id(span) + if sid is None: + return key = (_trace_id(span), sid) own_usage = token_usage_by_id.get(key, {}) if _has_nonzero_token_usage(own_usage) and not has_token_descendant(span): @@ -349,7 +351,9 @@ def collect_leaf_usage(span: Any, usage: dict[str, int]) -> None: return [replacements.get(id(span), span) for span in span_list] -def _build_langfuse_otlp_config(langfuse_config: dict = None) -> Optional[dict]: +def _build_langfuse_otlp_config( + langfuse_config: dict[str, Any] | None = None, +) -> dict[str, Any] | None: """Build Langfuse OTLP direct exporter config from explicit config or env.""" if langfuse_config: public_key = langfuse_config.get("public_key") or "" @@ -358,7 +362,9 @@ def _build_langfuse_otlp_config(langfuse_config: dict = None) -> Optional[dict]: else: public_key = os.getenv("LANGFUSE_PUBLIC_KEY", "") secret_key = os.getenv("LANGFUSE_SECRET_KEY", "") - host = os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") or "http://localhost:3000" + host = ( + os.getenv("LANGFUSE_BASE_URL") or os.getenv("LANGFUSE_HOST") or "http://localhost:3000" + ) if not public_key or not secret_key: return None @@ -561,9 +567,7 @@ def _apply_langfuse_auth_fallback(endpoint: str, headers: dict[str, str]) -> boo langfuse_headers = _build_langfuse_otlp_headers(public_key, secret_key) headers["Authorization"] = langfuse_headers["Authorization"] if not _has_header(headers, "x-langfuse-ingestion-version"): - headers["x-langfuse-ingestion-version"] = langfuse_headers[ - "x-langfuse-ingestion-version" - ] + headers["x-langfuse-ingestion-version"] = langfuse_headers["x-langfuse-ingestion-version"] return True @@ -595,9 +599,8 @@ def _build_generic_otlp_http_config() -> Optional[dict]: ) return None - raw_headers = ( - os.getenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "").strip() - or os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "") + raw_headers = os.getenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "").strip() or os.getenv( + "OTEL_EXPORTER_OTLP_HEADERS", "" ) headers = _parse_otlp_headers(raw_headers) langfuse_auth_fallback = _apply_langfuse_auth_fallback(endpoint, headers) @@ -618,13 +621,13 @@ def _build_generic_otlp_http_config() -> Optional[dict]: def setup_tracing( enable_inmemory: bool = True, - enable_langfuse: bool = None, # Auto-detect from env - langfuse_config: dict = None, + enable_langfuse: bool | None = None, # Auto-detect from env + langfuse_config: dict[str, Any] | None = None, enable_otlp: bool = False, otlp_endpoint: str = "localhost:4317", enable_adk_instrumentation: bool = True, # Auto-instrument ADK - use_callback_only: bool = None, # Explicit override - **kwargs + use_callback_only: bool | None = None, # Explicit override + **kwargs, ) -> Optional[InMemoryExporter]: """初始化 Tracing (支持多 Exporter) @@ -657,7 +660,7 @@ def setup_tracing( # 检查是否已有 TracerProvider (避免覆盖) existing_provider = trace.get_tracer_provider() - if existing_provider and hasattr(existing_provider, 'add_span_processor'): + if existing_provider and hasattr(existing_provider, "add_span_processor"): # 使用现有 provider,直接添加 processor provider = existing_provider logger.debug("Using existing TracerProvider") @@ -679,14 +682,16 @@ def setup_tracing( generic_otlp_config = _build_generic_otlp_http_config() if generic_otlp_config: try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, + ) - otlp_exporter = OTLPSpanExporter( + raw_otlp_exporter = HttpOTLPSpanExporter( endpoint=generic_otlp_config["endpoint"], headers=generic_otlp_config["headers"], ) otlp_exporter = _LoggingSpanExporter( - otlp_exporter, + raw_otlp_exporter, name="Generic OTLP", endpoint=generic_otlp_config["endpoint"], service_name=_get_service_name(), @@ -720,14 +725,16 @@ def setup_tracing( cloud_monitor_config = _build_cloud_monitor_otlp_http_config() if cloud_monitor_config: try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, + ) - cloud_monitor_exporter = OTLPSpanExporter( + raw_cloud_monitor_exporter = HttpOTLPSpanExporter( endpoint=cloud_monitor_config.endpoint, headers=cloud_monitor_config.headers, ) cloud_monitor_exporter = _LoggingSpanExporter( - cloud_monitor_exporter, + raw_cloud_monitor_exporter, name="CloudMonitor OTLP", endpoint=cloud_monitor_config.endpoint, service_name=cloud_monitor_config.service_name, @@ -757,9 +764,8 @@ def setup_tracing( langfuse_enabled = enable_langfuse if langfuse_enabled is None: # Auto-detect from environment variables - langfuse_enabled = ( - not generic_otlp_config - and bool(os.getenv("LANGFUSE_PUBLIC_KEY") or (langfuse_config or {}).get("public_key")) + langfuse_enabled = not generic_otlp_config and bool( + os.getenv("LANGFUSE_PUBLIC_KEY") or (langfuse_config or {}).get("public_key") ) # 检查是否应该禁用 LangfuseExporter (当使用 LangChain/LangGraph 时) @@ -769,16 +775,18 @@ def setup_tracing( if langfuse_enabled and not use_callback_only: try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, + ) config = _build_langfuse_otlp_config(langfuse_config) if config: - otlp_exporter = OTLPSpanExporter( + raw_langfuse_otlp_exporter = HttpOTLPSpanExporter( endpoint=config["endpoint"], headers=config["headers"], ) otlp_exporter = _LoggingSpanExporter( - otlp_exporter, + raw_langfuse_otlp_exporter, name="Langfuse OTLP", endpoint=config["endpoint"], service_name=_get_service_name(), @@ -786,11 +794,11 @@ def setup_tracing( span_transform=_prepare_langfuse_spans, ) provider.add_span_processor( - BatchSpanProcessor( - otlp_exporter, - **_batch_span_processor_kwargs(), + BatchSpanProcessor( + otlp_exporter, + **_batch_span_processor_kwargs(), + ) ) - ) logger.info( "Langfuse OTLP exporter enabled: %s (%s) headers=%s", config["endpoint"], @@ -810,11 +818,14 @@ def setup_tracing( # 5. OTLP Exporter (optional) if enable_otlp: try: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter - otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint) + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, + ) + + grpc_otlp_exporter = GrpcOTLPSpanExporter(endpoint=otlp_endpoint) provider.add_span_processor( BatchSpanProcessor( - otlp_exporter, + grpc_otlp_exporter, **_batch_span_processor_kwargs(), ) ) @@ -825,7 +836,10 @@ def setup_tracing( # 6. ADK Auto-Instrumentation (for Google ADK projects) if enable_adk_instrumentation and not _adk_instrumented: try: - from openinference.instrumentation.google_adk import GoogleADKInstrumentor + instrumentation_module = importlib.import_module( + "openinference.instrumentation.google_adk" + ) + GoogleADKInstrumentor = getattr(instrumentation_module, "GoogleADKInstrumentor") GoogleADKInstrumentor().instrument() _adk_instrumented = True logger.info("Google ADK instrumentation enabled") @@ -841,6 +855,7 @@ def setup_tracing( if enable_adk_instrumentation: try: from openinference.instrumentation.langchain import LangChainInstrumentor + LangChainInstrumentor().instrument() logger.info("LangChain instrumentation enabled") except ImportError: @@ -864,7 +879,7 @@ def shutdown_tracing(): if _langfuse_exporter is not None: try: - if hasattr(_langfuse_exporter, '_exporter'): + if hasattr(_langfuse_exporter, "_exporter"): _langfuse_exporter._exporter.shutdown() logger.debug("Langfuse exporter shutdown gracefully") except Exception: @@ -887,6 +902,7 @@ def get_tracer(name: str = "ksadk"): """获取 Tracer""" try: from opentelemetry import trace + return trace.get_tracer(name) except ImportError: return None diff --git a/ksadk/tracing/span_utils.py b/ksadk/tracing/span_utils.py index 7b76fb82..d89a8aea 100644 --- a/ksadk/tracing/span_utils.py +++ b/ksadk/tracing/span_utils.py @@ -93,9 +93,12 @@ def get_langfuse_callback( Langfuse callback handler 或 None """ try: - from ksadk.tracing import get_langfuse_callback_handler + import ksadk.tracing as tracing_module - handler = get_langfuse_callback_handler() + factory = getattr(tracing_module, "get_langfuse_callback_handler", None) + if not callable(factory): + return None + handler = factory() if handler: # 设置 session 和 metadata handler.session_id = session_id diff --git a/ksadk/tui/loop.py b/ksadk/tui/loop.py index ad3364c6..749c1355 100644 --- a/ksadk/tui/loop.py +++ b/ksadk/tui/loop.py @@ -3,6 +3,7 @@ Transcript 使用 ANSI 保留 rich 颜色和 Markdown 落定格式,composer 紧跟内容, 终端原生 scrollback 保留。支持键盘滚动、流式跟底和输入排队。 """ + from __future__ import annotations import asyncio @@ -105,7 +106,11 @@ def _codex_surface_rgb( # so 4% black on a light terminal is visually imperceptible; 8% gives the # input surface a stable edge without introducing a border. alpha = 0.08 if is_light and composer else 0.04 if is_light else 0.12 - return tuple(int(top[i] * alpha + background[i] * (1.0 - alpha)) for i in range(3)) + return ( + int(top[0] * alpha + background[0] * (1.0 - alpha)), + int(top[1] * alpha + background[1] * (1.0 - alpha)), + int(top[2] * alpha + background[2] * (1.0 - alpha)), + ) def _codex_surface_style(background: tuple[int, int, int] | None) -> str: @@ -319,12 +324,16 @@ def get_completions(self, document, complete_event): return # /model → 补全可选模型 id if prefix.startswith("/model ") and self.runner is not None: - model_prefix = prefix[len("/model "):].strip() + model_prefix = prefix[len("/model ") :].strip() available = getattr(self.runner, "available_models", None) or [] for m in available: mid = str(m.get("id") or m.get("name") or "") if mid and mid.startswith(model_prefix): - yield Completion(mid, start_position=-len(model_prefix), display_meta=m.get("display_name") or "") + yield Completion( + mid, + start_position=-len(model_prefix), + display_meta=m.get("display_name") or "", + ) return for command, meta in self._COMMANDS.items(): if command.startswith(prefix): @@ -372,7 +381,7 @@ async def on_text(self, full_text: str) -> None: self.assistant_entry.content = full_text self.assistant_entry.status = "streaming" if full_text.startswith(self._last_full_text): - delta = full_text[len(self._last_full_text):] + delta = full_text[len(self._last_full_text) :] else: # A final/snapshot event can replace the cumulative text. Preserve # ordering when possible; without tools it is safe to replace the @@ -398,7 +407,9 @@ async def on_thinking(self, full_thinking: str) -> None: self.assistant_entry.thinking = full_thinking # thinking 不进动态区,落定时随 assistant 一起渲染(show_thinking) - async def on_tool_call(self, tool_name: str, status: str, args: Any = None, call_id: str = "") -> None: + async def on_tool_call( + self, tool_name: str, status: str, args: Any = None, call_id: str = "" + ) -> None: if self.loop is None: return content = f"{tool_name} [{status}]" @@ -441,7 +452,7 @@ async def finalize(self) -> None: def final_entries(self, response: str) -> list[TranscriptEntry]: """Return finalized display cells in the original stream event order.""" if response and response.startswith(self._last_full_text): - delta = response[len(self._last_full_text):] + delta = response[len(self._last_full_text) :] if delta: if self._active_text_entry is None: self._active_text_entry = TranscriptEntry(role="assistant") @@ -507,7 +518,9 @@ def __init__( self._pin_to_bottom = True self._last_max_scroll = 0 self._history_pager_active = False - self._entry_ansi_cache: dict[int, str] = {} # 历史 entries ANSI 缓存(避免 streaming 全量重渲) + self._entry_ansi_cache: dict[int, str] = ( + {} + ) # 历史 entries ANSI 缓存(避免 streaming 全量重渲) self._last_usage: dict[str, Any] | None = None self._showed_help = False # 首次运行显示帮助列表 self._welcome_ansi: str | None = None @@ -600,6 +613,7 @@ def _build_application(self): multiline=True, history=InMemoryHistory(), ) + input_buffer = self._input_buffer # Codex uses an inline, content-first viewport: short transcripts keep # the composer directly below the content and a filler absorbs the rest @@ -771,13 +785,13 @@ def _build_application(self): @bindings.add("enter") def _submit(event) -> None: - text = self._input_buffer.text - self._input_buffer.reset(append_to_history=bool(text.strip())) + text = input_buffer.text + input_buffer.reset(append_to_history=bool(text.strip())) self._submit_text(text) @bindings.add("escape", "enter") def _newline(event) -> None: - self._input_buffer.insert_text("\n") + input_buffer.insert_text("\n") @bindings.add("c-c") def _interrupt_or_exit(event) -> None: @@ -796,7 +810,7 @@ def _picker_cancel(event) -> None: @bindings.add("c-d") def _exit_on_empty(event) -> None: - if not self._input_buffer.text: + if not input_buffer.text: event.app.exit() @bindings.add("pageup") @@ -835,14 +849,18 @@ def _composer_down(event) -> None: @bindings.add("up", filter=self._picker_condition) def _picker_up(event) -> None: if self._model_picker_models: - self._model_picker_index = (self._model_picker_index - 1) % len(self._model_picker_models) + self._model_picker_index = (self._model_picker_index - 1) % len( + self._model_picker_models + ) self._scroll_picker_to_selected() event.app.invalidate() @bindings.add("down", filter=self._picker_condition) def _picker_down(event) -> None: if self._model_picker_models: - self._model_picker_index = (self._model_picker_index + 1) % len(self._model_picker_models) + self._model_picker_index = (self._model_picker_index + 1) % len( + self._model_picker_models + ) self._scroll_picker_to_selected() event.app.invalidate() @@ -850,7 +868,11 @@ def _picker_down(event) -> None: def _picker_select(event) -> None: models = self._model_picker_models if models and 0 <= self._model_picker_index < len(models): - mid = str(models[self._model_picker_index].get("id") or models[self._model_picker_index].get("name") or "") + mid = str( + models[self._model_picker_index].get("id") + or models[self._model_picker_index].get("name") + or "" + ) if mid: self._apply_model_switch(mid) self._close_model_picker() @@ -980,7 +1002,9 @@ async def _run_turn_async( for entry in renderer.final_entries(renderer._last_full_text): await self._commit_entry(entry) if is_resume: - await self._commit_entry(TranscriptEntry(role="system", content="该 runtime 暂不支持审批续跑,已取消")) + await self._commit_entry( + TranscriptEntry(role="system", content="该 runtime 暂不支持审批续跑,已取消") + ) self._clear_current_task() self._drain_queue() else: @@ -992,7 +1016,9 @@ async def _run_turn_async( assistant_entry.status = "" for entry in renderer.final_entries(renderer._last_full_text): await self._commit_entry(entry) - await self._commit_entry(TranscriptEntry(role="system", content="已取消(保留已产生内容)")) + await self._commit_entry( + TranscriptEntry(role="system", content="已取消(保留已产生内容)") + ) self._clear_current_task() self._drain_queue() return @@ -1018,7 +1044,9 @@ def _handle_interrupt(self, exc: InterruptPending, input_data: dict[str, Any]) - async def _prompt(): await self._commit_entry( - TranscriptEntry(role="system", content=f"确认执行 {tool_name}? 输入 y 确认,其他内容取消") + TranscriptEntry( + role="system", content=f"确认执行 {tool_name}? 输入 y 确认,其他内容取消" + ) ) self._create_background_task(_prompt()) @@ -1032,7 +1060,9 @@ def _handle_interrupt_answer(self, user_input: str) -> None: if user_input.lower() in {"y", "yes"}: async def _resume(): - await self._commit_entry(TranscriptEntry(role="system", content="已确认,尝试续跑...")) + await self._commit_entry( + TranscriptEntry(role="system", content="已确认,尝试续跑...") + ) self._start_turn( "", input_data={**input_data, "resume": True}, @@ -1049,7 +1079,11 @@ async def _cancel(): self._create_background_task(_cancel()) def _drain_queue(self) -> None: - if self._pending_interrupt is not None or self._has_active_turn() or not self._queued_inputs: + if ( + self._pending_interrupt is not None + or self._has_active_turn() + or not self._queued_inputs + ): return item = self._queued_inputs.pop(0) self._start_turn(item.text, user_entry=item.entry) @@ -1167,7 +1201,7 @@ def _handle_command(self, user_input: str) -> str: def _handle_model_command(self, user_input: str) -> None: """/model:弹出交互式选择器(↑↓ Enter Esc);/model :直接切换(runnable 级,下轮生效)。""" - arg = user_input[len("/model"):].strip() + arg = user_input[len("/model") :].strip() if arg: # 直接切换:改 runner.model,下轮请求带新 model self._apply_model_switch(arg) @@ -1192,7 +1226,9 @@ def _apply_model_switch(self, model_id: str) -> None: """ self.runner.model = model_id available = getattr(self.runner, "available_models", None) or [] - matched = next((m for m in available if str(m.get("id") or m.get("name") or "") == model_id), None) + matched = next( + (m for m in available if str(m.get("id") or m.get("name") or "") == model_id), None + ) if matched: self.runner.model_metadata = matched # 清旧观察名,让 _current_model_name 用新 runner.model @@ -1263,8 +1299,8 @@ def _model_picker_fragments(self): frags: list[tuple[str, str]] = [] for i, m in enumerate(self._model_picker_models): mid = str(m.get("id") or m.get("name") or "") - is_cur_model = (mid == current) - is_selected = (i == self._model_picker_index) + is_cur_model = mid == current + is_selected = i == self._model_picker_index marker = "›" if is_selected else " " line = f"{marker} {i + 1}. {mid}" if is_cur_model: @@ -1276,7 +1312,7 @@ def _model_picker_fragments(self): # ---- streaming / transcript 渲染 ---- def _set_streaming(self, full_text: str) -> None: - """streaming 文本更新到当前 streaming entry(动态区 = transcript 末尾的 streaming entry)。""" + """更新 transcript 末尾的 streaming entry。""" if self._streaming_entry is not None: self._streaming_entry.content = full_text self._streaming_entry.status = "streaming" @@ -1365,6 +1401,7 @@ def _max_scroll(self) -> int: height = int(getattr(ri, "window_height", 0) or 0) if height <= 0: import shutil + height = max(10, (shutil.get_terminal_size(fallback=(80, 24)).lines or 24) - 6) line_count = self._transcript_line_count() return max(0, line_count - height) @@ -1522,18 +1559,22 @@ def _resolve_model_name(runner) -> str: return str(meta["id"]) observed = getattr(runner, "_observed_model", None) if observed: - return observed - return getattr(runner, "model", None) or os.getenv("MODEL_NAME") or "unknown" + return str(observed) + return str(getattr(runner, "model", None) or os.getenv("MODEL_NAME") or "unknown") def _help_text() -> str: - return "命令: /new 新会话 · /clear 清屏 · /session 查看 · /tools 展开工具 · exit 退出 · Ctrl-C 中断/退出" + return ( + "命令: /new 新会话 · /clear 清屏 · /session 查看 · /tools 展开工具 " + "· exit 退出 · Ctrl-C 中断/退出" + ) def _welcome_block(session_id: str, model_name: str, project_dir: Path, *, show_help: bool) -> str: """Render the compact Codex-style session card and one-line tip.""" try: from ksadk.version import VERSION + version = VERSION except Exception: version = "" @@ -1552,6 +1593,7 @@ def _welcome_block(session_id: str, model_name: str, project_dir: Path, *, show_ # 按终端宽度算框宽,留 transcript 左缩进 2 + 右 margin 2,上限 56(Codex 同款)。 try: import shutil + cols = shutil.get_terminal_size(fallback=(80, 24)).columns except Exception: cols = 80 @@ -1698,7 +1740,9 @@ def _render_entry_ansi( else: console.print(Markdown(_normalize_markdown(f"{body}{suffix}"))) else: - console.print(Text(suffix.lstrip(), style="dim") if suffix else Text("...", style="dim")) + console.print( + Text(suffix.lstrip(), style="dim") if suffix else Text("...", style="dim") + ) return _strip_ansi_backgrounds(console.export_text(styles=True)).rstrip() + "\n" if entry.role == "tool": @@ -1718,6 +1762,7 @@ def _render_entry_ansi( display_output = output try: import json as _json + parsed = _json.loads(output) # Tool adapters often JSON-encode an already serialized result. # Unwrap that second layer so structured output folds by fields @@ -1729,7 +1774,7 @@ def _render_entry_ansi( if nested.startswith("content="): import ast as _ast - quote = nested[len("content="):len("content=") + 1] + quote = nested[len("content=") : len("content=") + 1] if quote in {"'", '"'}: body_start = len("content=") + 1 body_end = body_start @@ -1756,6 +1801,7 @@ def _render_entry_ansi( pass # 估算显示行数:每行按 console width 折算(长字符串占多行) import shutil as _su + cw = max(40, (_su.get_terminal_size(fallback=(80, 24)).columns or 80) - 6) logical_lines = display_output.split("\n") # 估算显示行数:按显示宽度(CJK 占 2)折算,非 len @@ -1864,7 +1910,9 @@ def _clean(match: "re.Match[str]") -> str: return re.sub(r"\x1b\[([0-9;]*)m", _clean, text) -def run_tui(runner, *, show_thinking: bool = False, project_dir: str = ".", no_alt_screen: bool = False) -> None: +def run_tui( + runner, *, show_thinking: bool = False, project_dir: str = ".", no_alt_screen: bool = False +) -> None: """TUI 入口:被 cmd_invoke._invoke_tui / cmd_run._run_custom 调用。 ``no_alt_screen`` 作为兼容参数保留;TUI 现在默认使用 Codex-style inline viewport。 diff --git a/ksadk/tui/stream_render.py b/ksadk/tui/stream_render.py index 7b5805aa..9a4ef3b6 100644 --- a/ksadk/tui/stream_render.py +++ b/ksadk/tui/stream_render.py @@ -3,6 +3,7 @@ 从 RemoteRunner.stream 的归一化 chunk 提取 delta/usage/终止信号,格式化 usage 文本。 被 loop.py(交互 TUI)和 cmd_invoke._invoke_once(-m 单次)共用,保证两路径渲染口径一致。 """ + from __future__ import annotations import re diff --git a/ksadk/ui_config.py b/ksadk/ui_config.py index 1a4ab55e..b37930cb 100644 --- a/ksadk/ui_config.py +++ b/ksadk/ui_config.py @@ -6,7 +6,6 @@ from typing import Any, Dict, Optional, Tuple from urllib.parse import urlsplit - UI_PROFILE_AUTO = "auto" UI_PROFILE_ADK = "adk" UI_PROFILE_LANGCHAIN = "langchain" @@ -89,11 +88,14 @@ def default_ui_path(profile: str) -> str: return _DEFAULT_PATH_BY_PROFILE.get(profile, "/") -def extract_ui_state(state: Optional[Dict[str, Any]]) -> Tuple[Optional[str], Optional[str], Optional[str]]: +def extract_ui_state( + state: Optional[Dict[str, Any]], +) -> Tuple[Optional[str], Optional[str], Optional[str]]: if not isinstance(state, dict): return None, None, None - nested = state.get("ui") if isinstance(state.get("ui"), dict) else {} + nested_value = state.get("ui") + nested: Dict[str, Any] = nested_value if isinstance(nested_value, dict) else {} profile = state.get("ui_profile") or nested.get("profile") path = state.get("ui_path") or nested.get("path") diff --git a/ksadk/version.py b/ksadk/version.py index 3fc829a8..488944d0 100644 --- a/ksadk/version.py +++ b/ksadk/version.py @@ -1,4 +1,4 @@ """KsADK 版本信息""" -VERSION = "0.7.0" +VERSION = "0.8.0" __version__ = VERSION diff --git a/ksadk_runtime_common/schemas/runtime_event_v1.json b/ksadk_runtime_common/schemas/runtime_event_v1.json new file mode 100644 index 00000000..8f49edf8 --- /dev/null +++ b/ksadk_runtime_common/schemas/runtime_event_v1.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kingsoftcloud.github.io/ksadk-python/schemas/runtime_event_v1.json", + "title": "RuntimeEvent v1", + "description": "RuntimeEvent v1 信封 (goal-02 / G0.2 冻结)。additive 演进:只增字段/事件类型,不改既有字段语义。", + "type": "object", + "required": [ + "schema_version", + "event_id", + "event_type", + "timestamp", + "agent_id", + "user_id", + "session_id", + "invocation_id", + "seq_id", + "payload" + ], + "properties": { + "schema_version": {"const": 1}, + "event_id": {"type": "string", "minLength": 1}, + "event_type": {"type": "string", "minLength": 1}, + "timestamp": {"type": "number"}, + "agent_id": {"type": "string"}, + "user_id": {"type": "string"}, + "session_id": {"type": "string"}, + "invocation_id": {"type": "string"}, + "seq_id": {"type": "integer"}, + "phase": {"enum": ["commentary", "final_answer"]}, + "payload": {"type": "object"} + }, + "additionalProperties": false, + "allOf": [ + { + "if": {"properties": {"event_type": {"enum": ["text.delta", "text.completed"]}}}, + "then": {"properties": {"payload": {"required": ["text"]}}} + }, + { + "if": {"properties": {"event_type": {"enum": ["reasoning.delta", "reasoning.completed"]}}}, + "then": {"properties": {"payload": {"required": ["text"]}}} + }, + { + "if": {"properties": {"event_type": {"enum": ["tool.call.begin", "tool.call.end"]}}}, + "then": {"properties": {"payload": {"required": ["call_id", "name"]}}} + }, + { + "if": {"properties": {"event_type": {"enum": ["artifact.created", "artifact.updated"]}}}, + "then": {"properties": {"payload": {"required": ["name", "version"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "approval.requested"}}}, + "then": {"properties": {"payload": {"required": ["approval_id", "call_id", "kind"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "approval.resolved"}}}, + "then": {"properties": {"payload": {"required": ["approval_id", "call_id", "decision"]}}} + }, + { + "if": {"properties": {"event_type": {"enum": ["run.started", "run.progress", "run.interrupted", "run.completed", "run.canceled"]}}}, + "then": {"properties": {"payload": {"required": ["status"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "run.failed"}}}, + "then": {"properties": {"payload": {"required": ["status", "error"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "checkpoint.created"}}}, + "then": {"properties": {"payload": {"required": ["checkpoint_id", "granularity"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "checkpoint.resumed"}}}, + "then": {"properties": {"payload": {"required": ["checkpoint_id"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "usage.reported"}}}, + "then": {"properties": {"payload": {"required": ["input_tokens", "output_tokens", "total_tokens"]}}} + }, + { + "if": {"properties": {"event_type": {"enum": ["a2ui.surface.begin", "a2ui.surface.update", "a2ui.surface.end", "a2ui.interaction", "a2ui.action"]}}}, + "then": {"properties": {"payload": {"required": ["surface_id"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "a2a.task.created"}}}, + "then": {"properties": {"payload": {"required": ["task_id", "origin"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "a2a.task.status"}}}, + "then": {"properties": {"payload": {"required": ["task_id", "origin", "status"]}}} + }, + { + "if": {"properties": {"event_type": {"const": "a2a.task.artifact"}}}, + "then": {"properties": {"payload": {"required": ["task_id", "origin"]}}} + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 38e9d9d3..c84fe0bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ksadk" -version = "0.7.0" +version = "0.8.0" description = "KsADK Agent Runtime Platform - unified runtime, debugging, deployment and observability for AI agents" readme = "README.md" requires-python = ">=3.10" @@ -43,8 +43,17 @@ dependencies = [ "uvicorn>=0.23.0", "python-multipart>=0.0.9,<1.0.0", "python-socks>=2.7.1,<3.0.0", - "httpx>=0.24.0", - "a2a-sdk>=0.3.22", + # RuntimeLocalA2AExternalTransport pins the verified httpx/httpcore direct-pool ABI. + "httpx>=0.28.1,<0.29.0", + "httpcore>=1.0.9,<1.1.0", + # A2A wire 1.0 清洁重写基线 (goal-00): 首期精确锁定 1.1.0。 + # 旧 0.3.x 与 wire 1.0 API 不兼容,不能与 KsADK 0.8 共用一个环境。 + "a2a-sdk[fastapi,postgresql]==1.1.0", + # A2UI:官方 Python 包(ag-ui-a2ui-toolkit 等)当前仍对齐 v0.9,v1.0(2026-06 Candidate) + # 尚未进包。B 方案:守 G0.2 冻结 schema,前端已加 v1.0 消息名兼容层(createSurface/ + # updateComponents/deleteSurface),待官方包跟进 v1.0 后整体切换、撤自研 ksadk/a2ui/。 + # 详见 docs/a2ui-v1-alignment-proposal.md。 + "a2ui-core==0.1.1", "httpx-sse>=0.4.0", "sse-starlette>=2.1.0", # HTTP 请求 + AWS 签名 @@ -67,57 +76,72 @@ dependencies = [ "rapidocr-onnxruntime>=1.2.0", # ========= 核心框架依赖 (默认安装) ========= # LangChain - "langchain>=1.3.0,<2.0.0", - "langchain-openai>=1.2.0,<2.0.0", - "langchain-core>=1.4.0,<2.0.0", + "langchain>=1.3.14,<2.0.0", + "langchain-openai>=1.4.0,<2.0.0", + "langchain-core>=1.5.0,<2.0.0", # LangGraph - 使用兼容的版本范围 "langgraph>=1.2.0,<1.3.0", # ========= 可观测性 (默认安装) ========= - "opentelemetry-api==1.37.0", - "opentelemetry-sdk==1.37.0", - "opentelemetry-exporter-otlp==1.37.0", + # goal-00: 由 ==1.37.0 放宽到 [1.39, 1.41.1],取 adk 1.34.x(otel<=1.41.1)与 + # adk 2.x(otel 1.39~1.42.1)的交集,使默认即可解析到 adk 2.x(支持 2.x)。 + "opentelemetry-api>=1.39.0,<=1.41.1", + "opentelemetry-sdk>=1.39.0,<=1.41.1", + "opentelemetry-exporter-otlp>=1.39.0,<=1.41.1", ] [project.optional-dependencies] -# Google ADK 支持 +# Google ADK 支持 (goal-00: 1.34.x 为最低锚点,<3.0 上界;1.x/2.x 差异经 ksadk.compat.adk_compat 收口) adk = [ - "google-adk>=1.34.0,<2.0.0", + "google-adk>=1.34.0,<3.0.0", "litellm>=1.0.0; platform_system != 'Windows' or python_version < '3.13'", "json_repair>=0.25.0", # 用于修复大模型输出的非法 JSON ] # LangChain 支持 langchain = [ - "langchain>=1.3.0,<2.0.0", - "langchain-openai>=1.2.0,<2.0.0", - "langchain-core>=1.4.0,<2.0.0", + "langchain>=1.3.14,<2.0.0", + "langchain-openai>=1.4.0,<2.0.0", + "langchain-core>=1.5.0,<2.0.0", ] # LangGraph 支持 langgraph = [ "langgraph>=1.2.0,<1.3.0", - "langchain>=1.3.0,<2.0.0", - "langchain-openai>=1.2.0,<2.0.0", + "langgraph-checkpoint-sqlite>=2.0.0", + "langchain>=1.3.14,<2.0.0", + "langchain-openai>=1.4.0,<2.0.0", "protobuf>=6.32.1", ] # DeepAgents 支持(基于 LangGraph) deepagents = [ "deepagents>=0.6.2,<1.0.0; python_version >= '3.11'", "langgraph>=1.2.0,<1.3.0", - "langchain>=1.3.0,<2.0.0", - "langchain-openai>=1.2.0,<2.0.0", + "langchain>=1.3.14,<2.0.0", + "langchain-openai>=1.4.0,<2.0.0", ] -# A2A 协议集成 +# A2A 协议集成 (goal-00: 与主依赖一致精确锁定 wire 1.0 SDK) a2a = [ - "a2a-sdk[http-server]>=0.3.22", + "a2a-sdk[fastapi,postgresql]==1.1.0", ] # 可观测性 tracing = [ "openinference-instrumentation-langchain>=0.1.0", + "langfuse>=3.0.0", ] # 知识库 & 记忆库支持 kb = [] # Skill Runtime E2B backend skills = [ - "e2b>=2.0.0", + "e2b>=2.15.3,<2.25.0", +] +# Codex Runtime (goal-09): OpenAI 官方 openai-codex SDK,自带 pin 版 CLI 二进制。 +# 可选 extra,不进默认依赖(二进制几十 MB);缺依赖时 CodexRuntime 显式报错。 +codex = [ + "openai-codex==0.144.4", +] +# Official AG-UI transport. Optional until Hosted UI selects it from bootstrap. +agui = [ + "ag-ui-protocol==0.1.19", + "ag-ui-langgraph[fastapi]==0.0.42", + "copilotkit==0.1.94", ] # 开发依赖 dev = [ @@ -129,10 +153,15 @@ dev = [ "black>=22.0.0", "ruff>=0.1.0", "mypy>=1.0.0", + "types-PyYAML>=6.0.0", + "types-protobuf>=6.32.0", + "types-python-dateutil>=2.9.0", + "types-qrcode>=8.2.0", + "types-requests>=2.32.0", ] # 全部依赖 all = [ - "ksadk[a2a,adk,langchain,langgraph,deepagents,kb,skills,tracing,dev]", + "ksadk[a2a,adk,agui,langchain,langgraph,deepagents,kb,skills,tracing,dev]", ] [project.scripts] @@ -159,6 +188,8 @@ target-version = ['py310'] [tool.ruff] line-length = 100 + +[tool.ruff.lint] select = ["E", "F", "W", "I"] [tool.mypy] diff --git a/scripts/open_source_audit.py b/scripts/open_source_audit.py index 09875a98..922fd10c 100644 --- a/scripts/open_source_audit.py +++ b/scripts/open_source_audit.py @@ -591,7 +591,24 @@ def git_files(root: Path) -> list[str]: def filesystem_files(root: Path) -> list[str]: - ignored_dirs = {".git", "__pycache__"} + # A clean export intentionally has no .git directory. Release preflight may + # create a virtualenv, Web cache, docs build output, and Python artifacts in + # that export; none of them is source intended for public import. + ignored_dirs = { + ".cache", + ".git", + ".mypy_cache", + ".next", + ".pytest_cache", + ".ruff_cache", + ".venv", + "__pycache__", + "build", + "dist", + "dist-alias", + "node_modules", + "out", + } paths: list[str] = [] for path in sorted(root.rglob("*")): if any(part in ignored_dirs for part in path.relative_to(root).parts): diff --git a/scripts/prepare_ksadk_python_export.py b/scripts/prepare_ksadk_python_export.py index c5acf254..8313d939 100644 --- a/scripts/prepare_ksadk_python_export.py +++ b/scripts/prepare_ksadk_python_export.py @@ -18,8 +18,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Iterable, Sequence - +from typing import Sequence REPO_ROOT = Path(__file__).resolve().parents[1] TARGET_REPOSITORY = "https://github.com/kingsoftcloud/ksadk-python" @@ -94,6 +93,7 @@ "scripts/prepare_ksadk_python_export.py", "scripts/prepare_ksadk_web_export.py", "scripts/public_secret_audit.py", + "scripts/verify_ksadk_web_static.py", } PUBLIC_TEST_FILES = { @@ -105,7 +105,12 @@ "tests/test_open_source_audit.py", "tests/test_public_release_positioning.py", "tests/test_runtime_common_packaging.py", + "tests/test_managed_runtime_builder.py", + "tests/test_managed_runtime_native_smoke.py", + "tests/test_managed_runtime_resolution.py", "tests/test_tracing_setup_otlp.py", + "tests/cli/test_cmd_create_codex.py", + "tests/runners/test_codex_runner.py", } EXCLUDED_PREFIXES = ( @@ -164,7 +169,15 @@ def normalize(path: str | Path) -> str: def git_files(root: Path) -> list[str]: completed = subprocess.run( - ["git", "-c", "core.quotePath=false", "ls-files", "--cached", "--others", "--exclude-standard"], + [ + "git", + "-c", + "core.quotePath=false", + "ls-files", + "--cached", + "--others", + "--exclude-standard", + ], cwd=root, check=True, text=True, @@ -209,7 +222,11 @@ def is_excluded(path: str) -> bool: return True if normalized.startswith("docs/reference/") and normalized not in CURATED_REFERENCE_DOCS: return True - if normalized.startswith("docs/") and normalized not in CURATED_DOCS and normalized not in CURATED_REFERENCE_DOCS: + if ( + normalized.startswith("docs/") + and normalized not in CURATED_DOCS + and normalized not in CURATED_REFERENCE_DOCS + ): return True if normalized.endswith(EXCLUDED_SUFFIXES): return True @@ -319,7 +336,11 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) parser.add_argument("--output-dir", type=Path) parser.add_argument("--json", action="store_true", help="print machine-readable JSON") - parser.add_argument("--summary", action="store_true", help="print a concise human-readable summary") + parser.add_argument( + "--summary", + action="store_true", + help="print a concise human-readable summary", + ) return parser.parse_args(argv) diff --git a/scripts/public_secret_audit.py b/scripts/public_secret_audit.py index 0c7ecdb3..dd8e74b3 100644 --- a/scripts/public_secret_audit.py +++ b/scripts/public_secret_audit.py @@ -4,24 +4,62 @@ import subprocess from pathlib import Path - ROOT = Path(__file__).resolve().parents[1] -SKIP_SUFFIXES = {".pyc", ".pyo", ".so", ".dylib", ".dll", ".zip", ".whl", ".png", ".jpg", ".jpeg", ".gif"} +SKIP_SUFFIXES = { + ".pyc", + ".pyo", + ".so", + ".dylib", + ".dll", + ".zip", + ".whl", + ".png", + ".jpg", + ".jpeg", + ".gif", +} +SKIP_DIRECTORY_NAMES = { + ".cache", + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".venv", + "build", + "dist", + "dist-alias", + "node_modules", +} PATTERN = re.compile( - r"pypi-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC|DSA) PRIVATE KEY|SecretAccessKey\s*[:=]\s*[^<\s]+" + r"pypi-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|BEGIN (RSA|OPENSSH|EC|DSA) " + r"PRIVATE KEY|SecretAccessKey\s*[:=]\s*[^<\s]+" ) -def main() -> int: - hits: list[str] = [] +def _source_files() -> list[str]: + """Return tracked files, or clean-export files when no Git metadata exists.""" tracked = subprocess.run( ["git", "ls-files"], cwd=ROOT, - check=True, capture_output=True, text=True, - ).stdout.splitlines() - for relative in tracked: + ) + if tracked.returncode == 0: + return tracked.stdout.splitlines() + + return [ + str(path.relative_to(ROOT)) + for path in ROOT.rglob("*") + if path.is_file() + and not any( + part in SKIP_DIRECTORY_NAMES for part in path.relative_to(ROOT).parts + ) + ] + + +def main() -> int: + hits: list[str] = [] + for relative in _source_files(): path = ROOT / relative if not path.is_file(): continue diff --git a/scripts/verify_ksadk_web_static.py b/scripts/verify_ksadk_web_static.py new file mode 100644 index 00000000..3fd4c089 --- /dev/null +++ b/scripts/verify_ksadk_web_static.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Verify the Web static payload embedded in a KsADK release artifact. + +The Python package intentionally consumes the published ``dist-ksadk`` payload +instead of rebuilding a checkout of ksadk-web. This checker makes that boundary +explicit: the source package version must match the requested pin and every +static file in the wheel must byte-match the payload extracted from the same +tarball. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import zipfile +from pathlib import Path + +STATIC_PREFIX = "ksadk/server/static/" + + +def _tree_digest(root: Path) -> tuple[str, tuple[str, ...]]: + if not root.is_dir(): + raise ValueError(f"static directory does not exist: {root}") + + digest = hashlib.sha256() + paths: list[str] = [] + for path in sorted(item for item in root.rglob("*") if item.is_file()): + relative = path.relative_to(root).as_posix() + paths.append(relative) + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256(path.read_bytes()).digest()) + digest.update(b"\n") + if not paths: + raise ValueError(f"static directory is empty: {root}") + return digest.hexdigest(), tuple(paths) + + +def _wheel_digest(wheel: Path) -> tuple[str, tuple[str, ...]]: + if not wheel.is_file(): + raise ValueError(f"wheel does not exist: {wheel}") + + digest = hashlib.sha256() + paths: list[str] = [] + with zipfile.ZipFile(wheel) as archive: + for name in sorted( + entry for entry in archive.namelist() if entry.startswith(STATIC_PREFIX) + ): + if name.endswith("/"): + continue + relative = name.removeprefix(STATIC_PREFIX) + paths.append(relative) + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256(archive.read(name)).digest()) + digest.update(b"\n") + if not paths: + raise ValueError(f"wheel does not contain {STATIC_PREFIX}: {wheel}") + return digest.hexdigest(), tuple(paths) + + +def _require_package_version(package_root: Path, expected_version: str) -> None: + package_json = package_root / "package.json" + if not package_json.is_file(): + raise ValueError(f"Web package metadata missing: {package_json}") + metadata = json.loads(package_json.read_text(encoding="utf-8")) + actual_version = str(metadata.get("version") or "") + if actual_version != expected_version: + raise ValueError( + "Web package version mismatch: " + f"expected {expected_version}, got {actual_version or ''}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--expected", required=True, type=Path) + parser.add_argument("--actual", type=Path) + parser.add_argument("--wheel", type=Path) + parser.add_argument("--package-root", type=Path) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + + if bool(args.actual) == bool(args.wheel): + parser.error("provide exactly one of --actual or --wheel") + + try: + expected_digest, expected_paths = _tree_digest(args.expected) + if args.package_root: + _require_package_version(args.package_root, args.expected_version) + if args.actual: + actual_digest, actual_paths = _tree_digest(args.actual) + subject = str(args.actual) + else: + actual_digest, actual_paths = _wheel_digest(args.wheel) + subject = str(args.wheel) + except (OSError, ValueError, json.JSONDecodeError, zipfile.BadZipFile) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + if expected_paths != actual_paths or expected_digest != actual_digest: + print( + "ERROR: KsADK Web static payload does not match the pinned dist-ksadk", + file=sys.stderr, + ) + print(f" expected: {args.expected} ({expected_digest})", file=sys.stderr) + print(f" actual: {subject} ({actual_digest})", file=sys.stderr) + return 1 + + print( + f"Verified KsADK Web {args.expected_version} static payload: " + f"{len(expected_paths)} files, sha256={expected_digest}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/cli/test_cmd_create_codex.py b/tests/cli/test_cmd_create_codex.py new file mode 100644 index 00000000..0e685c77 --- /dev/null +++ b/tests/cli/test_cmd_create_codex.py @@ -0,0 +1,126 @@ +"""ksadk init --framework codex 脚手架 + builder codex 依赖单测(假数据,不打网络)。""" + +from pathlib import Path + +import yaml +from click.testing import CliRunner + +from ksadk.builders.framework_requirements import ( + code_requirements_for_framework, + minimal_requirements_for_framework, + requirements_for_framework, +) +from ksadk.cli.cmd_create import _write_codex_project_config +from ksadk.cli.cmd_create import create as create_command +from ksadk.detection.detector import FrameworkDetector, FrameworkType + + +def test_write_codex_project_config_files(tmp_path): + """codex 项目:agentengine.yaml(framework:codex)+requirements+README,无 agent.py。""" + _write_codex_project_config(tmp_path, "my-codex-agent") + yaml_text = (tmp_path / "agentengine.yaml").read_text(encoding="utf-8-sig") + assert "framework: codex" in yaml_text + assert "artifact_type: ManagedRuntime" in yaml_text + assert "name: codex" in yaml_text + assert "model: glm-5.2" in yaml_text + assert "prompt:" in yaml_text + # requirements 是 ksadk[codex] + assert "ksadk[codex]" in (tmp_path / "requirements.txt").read_text(encoding="utf-8-sig") + # 无 package 目录 / 无 agent.py(codex 逻辑由 prompt 承载) + assert not (tmp_path / "my-codex-agent").exists() + assert not list(tmp_path.glob("**/agent.py")) + assert not (tmp_path / "codex.yaml").exists() + # README 说明 codex 运行方式 + readme = (tmp_path / "README.md").read_text(encoding="utf-8-sig") + assert "ksadk web" in readme + manifest = yaml.safe_load(yaml_text) + # Version is deliberately resolved by the catalog in cloud. Init must not + # accidentally pin to the SDK installed on the developer's machine. + assert manifest["runtime"] == {"name": "codex"} + + +def test_detector_recognizes_codex_project(tmp_path): + """detector 对 codex 项目识别为 FrameworkType.CODEX。""" + _write_codex_project_config(tmp_path, "my-codex-agent") + result = FrameworkDetector(str(tmp_path)).detect() + assert result.type == FrameworkType.CODEX + + +def test_framework_requirements_codex(): + """Codex 只进入 Linux container/runtime,不进入宿主机 Code zip。""" + assert requirements_for_framework("codex") == ["openai-codex==0.144.4"] + assert minimal_requirements_for_framework("codex") == ["openai-codex==0.144.4"] + assert code_requirements_for_framework("codex") == [] + # 大小写/空白归一 + assert requirements_for_framework(" Codex ") == ["openai-codex==0.144.4"] + + +def test_create_codex_cli_warns_when_sdk_missing_but_completes(monkeypatch): + """真实 init 命令在缺 SDK 时提示,但仍生成完整 canonical 项目。""" + import importlib.util + + original_find_spec = importlib.util.find_spec + monkeypatch.setattr( + importlib.util, + "find_spec", + lambda name: None if name == "openai_codex" else original_find_spec(name), + ) + monkeypatch.setattr( + "ksadk.configs.global_config.global_config_exists", + lambda: False, + ) + + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + create_command, + ["--framework", "codex", "my-codex-agent"], + ) + project = Path("my-codex-agent") + + assert result.exit_code == 0, result.output + assert "ksadk[codex]" in result.output + assert (project / "agentengine.yaml").exists() + assert (project / ".env").exists() + assert (project / "requirements.txt").exists() + assert (project / "README.md").exists() + assert not (project / "codex.yaml").exists() + assert not list(project.glob("**/agent.py")) + + +def test_create_codex_env_only_contains_local_model_configuration(monkeypatch): + """Codex init must not copy deploy, KS3, or observability credentials into .env.""" + monkeypatch.setattr( + "ksadk.configs.global_config.global_config_exists", + lambda: True, + ) + monkeypatch.setattr( + "ksadk.configs.global_config.get_env_from_global_config", + lambda: { + "OPENAI_API_KEY": "model-key", + "OPENAI_BASE_URL": "http://model-gateway.internal/v1", + "OPENAI_MODEL_NAME": "glm-5.2", + "KSYUN_ACCESS_KEY": "must-not-copy", + "KSYUN_SECRET_KEY": "must-not-copy", + "LANGFUSE_SECRET_KEY": "must-not-copy", + }, + ) + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(create_command, ["--framework", "codex", "my-codex-agent"]) + assert result.exit_code == 0, result.output + env_text = (Path("my-codex-agent") / ".env").read_text(encoding="utf-8-sig") + + assert "OPENAI_API_KEY=model-key" not in env_text + assert "# OPENAI_API_KEY=" in env_text + # Keep an explicitly configured internal endpoint intact; init is not a + # policy-enforcement layer. + assert "OPENAI_BASE_URL=http://model-gateway.internal/v1" in env_text + assert "KSYUN_" not in env_text + assert "LANGFUSE_" not in env_text + + +def test_requirements_no_codex_when_other_framework(): + """其他框架不受影响(adk/langchain 不含 codex 依赖)。""" + assert "openai-codex" not in requirements_for_framework("adk") + assert "openai-codex" not in requirements_for_framework("langchain") diff --git a/tests/conftest.py b/tests/conftest.py index 30cf3088..8e85c96a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from ksadk.cli.ui import OUTPUT_MODE_PRETTY, configure_ui_runtime +from ksadk.cli.ui import OUTPUT_MODE_PRETTY, configure_ui_runtime # noqa: E402 @pytest.fixture(autouse=True) diff --git a/tests/runners/test_codex_runner.py b/tests/runners/test_codex_runner.py new file mode 100644 index 00000000..ab635d3b --- /dev/null +++ b/tests/runners/test_codex_runner.py @@ -0,0 +1,120 @@ +"""CodexRunner 单测:用 fake AsyncCodexClient 不打网络,测 stream 反投射 + invoke + cancel。""" + +import asyncio + +from ksadk.codex.client import CodexClient +from ksadk.codex.runtime import CodexRuntime +from ksadk.runners.codex_runner import CodexRunner + + +class _FakeCodex(CodexClient): + """假 codex 后端:发 commentary delta + final_answer completed,模拟 codex 流。""" + + def __init__(self) -> None: + self.started: list[str] = [] + self.interrupted: list[str] = [] + self.thread_configs: list = [] # 记录 start_thread 收到的 config(model/base_instructions) + self._seq = 0 + + async def start_thread(self, config=None) -> str: + self._seq += 1 + tid = f"thread_{self._seq}" + self.started.append(tid) + self.thread_configs.append(config or {}) + return tid + + async def resume_thread(self, thread_id, config=None) -> str: + return thread_id + + def run_turn(self, thread_id, prompt, *, config=None): + async def gen(): + # 真实 codex 流:item/started(建 phase 上下文) → delta(多个) → completed + yield {"method": "item/started", + "params": {"item": {"id": "rs1", "type": "reasoning", + "phase": "commentary"}}} + yield {"method": "item/agentMessage/delta", + "params": {"delta": "想一下", "item_id": "rs1"}} + yield {"method": "item/completed", + "params": {"item": {"id": "rs1", "type": "reasoning", "phase": "commentary", + "summary": ["想一下"]}}} + yield {"method": "item/started", + "params": {"item": {"id": "m1", "type": "agentMessage", + "phase": "final_answer"}}} + yield {"method": "item/agentMessage/delta", + "params": {"delta": "你好", "item_id": "m1"}} + yield {"method": "item/completed", + "params": {"item": {"id": "m1", "type": "agentMessage", + "phase": "final_answer", "text": "你好"}}} + + return gen() + + async def interrupt_active_turn(self, thread_id) -> bool: + self.interrupted.append(thread_id) + return True + + async def close(self) -> None: + return None + + +def _make_runner(monkeypatch): + # 跳过 AsyncCodexClient 真实 SDK 构造(它 lazy import openai_codex),直接注入 fake + runner = CodexRunner.__new__(CodexRunner) + runner.detection_result = type("D", (), {"name": "codex-agent", "type": None})() + runner.project_dir = "." + runner._agent = None + runner._client = _FakeCodex() + runner._runtime = CodexRuntime(runner._client, sandbox_read_only=True) + runner._handles = {} + return runner + + +def test_stream_projects_text_and_thinking(): + runner = _make_runner(None) + chunks = asyncio.run(_collect(runner.stream({"input": "hi", "session_id": "s1"}))) + types = [c.get("type") for c in chunks] + # commentary delta -> thinking;final_answer -> text;末尾 RUN_COMPLETED -> final + assert "thinking" in types + assert "text" in types + assert types[-1] == "final" + final = chunks[-1] + assert "你好" in final["output"] # accumulated 文本 + + +def test_invoke_aggregates_final_output(): + runner = _make_runner(None) + result = asyncio.run(runner.invoke({"input": "hi", "session_id": "s2"})) + assert "你好" in result["output"] + + +def test_load_agent_is_noop(): + runner = _make_runner(None) + runner.load_agent() # 不抛异常即通过 + assert runner._agent is True + + +def test_stream_passes_model_and_prompt_to_thread(): + """C:yaml 的 model/prompt 经 raw_config 传给 codex thread(model + base_instructions)。""" + runner = _make_runner(None) + # 模拟 detector 从 ksadk.yaml 读出的 raw_config + runner.detection_result.raw_config = {"model": "glm-5.2", "prompt": "你是编码助手"} + asyncio.run(_collect(runner.stream({"input": "hi", "session_id": "s9"}))) + cfg = runner._client.thread_configs[-1] + assert cfg["model"] == "glm-5.2" + assert cfg["base_instructions"] == "你是编码助手" + assert cfg["sandbox_read_only"] is True + + +def test_stream_input_model_overrides_yaml(): + """C:本轮请求的 model 优先于 yaml(raw_config)。""" + runner = _make_runner(None) + runner.detection_result.raw_config = {"model": "yaml-model"} + asyncio.run(_collect(runner.stream({"input": "hi", "session_id": "s10", "model": "glm-5.1"}))) + cfg = runner._client.thread_configs[-1] + assert cfg["model"] == "glm-5.1" + + +async def _collect(agen): + out = [] + async for c in agen: + out.append(c) + return out diff --git a/tests/test_a2a_agent_card.py b/tests/test_a2a_agent_card.py new file mode 100644 index 00000000..bbe232ee --- /dev/null +++ b/tests/test_a2a_agent_card.py @@ -0,0 +1,124 @@ +"""A2A 1.0 AgentCard wire-contract tests. + +The public JSON is deliberately generated and reparsed with the pinned official +``a2a-sdk`` protobuf classes. This prevents KsADK-specific aliases or legacy +0.3 fields from silently escaping through the local A2A surface. +""" + +from __future__ import annotations + +import json + +from a2a.types import AgentCard +from click.testing import CliRunner +from google.protobuf.json_format import MessageToDict, ParseDict + +from ksadk.a2a.card import A2A_PROTOCOL_VERSION, build_agent_card +from ksadk.cli.cmd_a2a import a2a + +# Snapshot of the current main ``specification/a2a.proto`` AgentCard JSON names. +# The public protocol target for the unreleased 0.8 candidate is A2A main, not +# any legacy 0.3 Card shape. +_A2A_MAIN_AGENT_CARD_FIELDS = { + "name", + "description", + "supportedInterfaces", + "provider", + "version", + "documentationUrl", + "capabilities", + "securitySchemes", + "securityRequirements", + "defaultInputModes", + "defaultOutputModes", + "skills", + "signatures", + "iconUrl", +} + + +def test_agent_card_uses_the_official_a2a_1_wire_shape(): + card = build_agent_card( + name="research-agent", + base_url="https://agents.example.com/", + description="Answers research questions.", + version="1.2.3", + skills=["research"], + ) + + payload = MessageToDict(card, preserving_proto_field_name=False) + + assert { + field.json_name for field in AgentCard.DESCRIPTOR.fields + } == _A2A_MAIN_AGENT_CARD_FIELDS + assert payload == { + "name": "research-agent", + "description": "Answers research questions.", + "supportedInterfaces": [ + { + "url": "https://agents.example.com/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": A2A_PROTOCOL_VERSION, + }, + { + "url": "https://agents.example.com/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": A2A_PROTOCOL_VERSION, + }, + ], + "version": "1.2.3", + "capabilities": {"streaming": True, "pushNotifications": False}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "research", + "name": "Research", + "description": "Skill: research", + "tags": ["research"], + } + ], + } + assert not {"url", "preferredTransport", "additionalInterfaces"} & payload.keys() + assert set(payload).issubset(_A2A_MAIN_AGENT_CARD_FIELDS) + + reparsed = ParseDict(payload, AgentCard()) + assert MessageToDict(reparsed, preserving_proto_field_name=False) == payload + + +def test_a2a_card_cli_emits_the_official_wire_shape(tmp_path): + (tmp_path / "agentengine.yaml").write_text( + "name: local-codex\nframework: codex\n", + encoding="utf-8", + ) + + result = CliRunner().invoke( + a2a, + [ + "card", + str(tmp_path), + "--url", + "https://agent.example.com", + "--name", + "research-agent", + "--description", + "Answers research questions.", + "--skill", + "research", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["supportedInterfaces"][0] == { + "url": "https://agent.example.com/a2a/jsonrpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + } + assert payload["supportedInterfaces"][1] == { + "url": "https://agent.example.com/a2a/v1", + "protocolBinding": "HTTP+JSON", + "protocolVersion": "1.0", + } + assert "url" not in payload + ParseDict(payload, AgentCard()) diff --git a/tests/test_check_approval_record.py b/tests/test_check_approval_record.py index 553c1589..ea43f9dd 100644 --- a/tests/test_check_approval_record.py +++ b/tests/test_check_approval_record.py @@ -6,7 +6,6 @@ import sys from pathlib import Path - REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT_PATH = REPO_ROOT / "scripts" / "check_approval_record.py" @@ -150,8 +149,7 @@ def test_filled_record_passes_when_source_references_include_current_commit(tmp_ module = _load_module() record = tmp_path / "approval.md" record.write_text( - _approved_record("reviewed export from new-reviewed-commit") - .replace( + _approved_record("reviewed export from new-reviewed-commit").replace( "- `ksadk-web`: /tmp/ksadk-web-export-candidate", "- `ksadk-web`: /tmp/ksadk-web-export-candidate at new-reviewed-commit", ), diff --git a/tests/test_check_publication_state.py b/tests/test_check_publication_state.py index b89cd2c0..21361f40 100644 --- a/tests/test_check_publication_state.py +++ b/tests/test_check_publication_state.py @@ -7,7 +7,6 @@ import pytest - SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "check_publication_state.py" @@ -211,7 +210,10 @@ def test_publication_state_fails_when_github_homepage_points_elsewhere(monkeypat module = _load_module() def fake_open(_url): - return 200, b'{"homepage":"https://kingsoftcloud.github.io/ksadk-python/getting-started/quickstart/"}' + return ( + 200, + b'{"homepage":"https://kingsoftcloud.github.io/ksadk-python/getting-started/quickstart/"}', + ) monkeypatch.setattr(module, "_open", fake_open) diff --git a/tests/test_config_env_registry.py b/tests/test_config_env_registry.py index 9724d022..fea8d97e 100644 --- a/tests/test_config_env_registry.py +++ b/tests/test_config_env_registry.py @@ -35,10 +35,10 @@ def test_env_registry_docs_cover_registered_names(): assert item.name in doc_text -def test_env_registry_defaults_ksadk_web_static_sync_to_latest_npm_release(): +def test_env_registry_pins_ksadk_web_static_sync_to_a_published_npm_release(): specs = {item.name: item for item in ENV_VAR_REGISTRY} - assert specs["KSADK_WEB_VERSION"].default == "latest" + assert specs["KSADK_WEB_VERSION"].default == "0.3.0" assert specs["KSADK_WEB_PACKAGE"].default == "@kingsoftcloud/ksadk-web" assert specs["KSADK_WEB_RELEASE_URL"].default == "" diff --git a/tests/test_managed_runtime_builder.py b/tests/test_managed_runtime_builder.py new file mode 100644 index 00000000..b5064199 --- /dev/null +++ b/tests/test_managed_runtime_builder.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import hashlib +import json +import zipfile + +import yaml +from click.testing import CliRunner + +from ksadk.builders.framework_requirements import ( + code_requirements_for_framework, + requirements_for_framework, +) +from ksadk.builders.managed_runtime_builder import ManagedRuntimeBuilder +from ksadk.cli.cmd_build import build as build_command +from ksadk.cli.cmd_deploy import _resolve_artifact_type_input +from ksadk.cli.workflow_common import plan_artifact_build + + +def _write_codex_project(tmp_path, *, runtime_version: str | None = "0.144.4"): + runtime = {"name": "codex"} + if runtime_version is not None: + runtime["version"] = runtime_version + config = { + "name": "managed-codex", + "version": "1.2.3", + "framework": "codex", + "artifact_type": "ManagedRuntime", + "runtime": runtime, + "model": "glm-5.2", + "prompt": "You are a coding assistant.", + "deploy": {"resources": {"cpu": "2"}}, + } + (tmp_path / "agentengine.yaml").write_text( + yaml.safe_dump(config, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + (tmp_path / ".env").write_text("OPENAI_API_KEY=secret\n", encoding="utf-8") + (tmp_path / "requirements.txt").write_text("ksadk[codex]\n", encoding="utf-8") + (tmp_path / "agent.py").write_text("raise RuntimeError('must not be bundled')\n") + return config + + +def test_managed_runtime_builder_emits_manifest_only_bundle(tmp_path): + _write_codex_project(tmp_path) + + result = ManagedRuntimeBuilder(tmp_path).build() + + assert result.success is True + assert result.artifact_path is not None + assert result.artifact_path.name == "managed-codex-1.2.3-runtime.zip" + with zipfile.ZipFile(result.artifact_path) as archive: + assert archive.namelist() == ["agentengine.yaml", "runtime-lock.json"] + manifest_bytes = archive.read("agentengine.yaml") + manifest = yaml.safe_load(manifest_bytes) + lock = json.loads(archive.read("runtime-lock.json")) + + assert set(manifest) == { + "name", + "version", + "framework", + "artifact_type", + "runtime", + "model", + "prompt", + } + assert manifest["artifact_type"] == "ManagedRuntime" + assert manifest["runtime"] == {"name": "codex", "version": "0.144.4"} + assert lock == { + "schema_version": "runtime-manifest/v1", + "runtime": {"name": "codex", "version": "0.144.4"}, + "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), + } + + +def test_managed_runtime_builder_requires_resolved_version(tmp_path): + _write_codex_project(tmp_path, runtime_version=None) + + result = ManagedRuntimeBuilder(tmp_path).build() + + assert result.success is False + assert "runtime.version" in (result.error_message or "") + + +def test_managed_runtime_dependency_policy_keeps_codex_out_of_code_zip(): + assert code_requirements_for_framework("codex") == [] + assert requirements_for_framework("codex") == ["openai-codex==0.144.4"] + + +def test_deploy_resolves_managed_runtime_from_config(): + config = {"framework": "codex", "artifact_type": "ManagedRuntime"} + + assert _resolve_artifact_type_input(config, None) == "ManagedRuntime" + assert _resolve_artifact_type_input(config, "Container") == "Container" + + +def test_managed_runtime_deploy_has_no_ks3_artifact_reference(): + plan = plan_artifact_build( + target="serverless", + artifact_type="ManagedRuntime", + ks3_path=None, + image=None, + no_cache=False, + ) + external = plan_artifact_build( + target="serverless", + artifact_type="ManagedRuntime", + ks3_path="ks3://bucket/agent/runtime.zip", + image=None, + no_cache=False, + ) + + assert plan.should_build is False + assert plan.should_publish is False + assert external.should_build is False + assert external.explicit_ref_option is None + + +def test_build_command_auto_selects_managed_runtime(tmp_path): + _write_codex_project(tmp_path) + + result = CliRunner().invoke(build_command, [str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert ( + tmp_path / ".agentengine" / "managed_runtime" / "managed-codex-1.2.3-runtime.zip" + ).exists() + + +def test_build_command_rejects_forced_code_mode_for_codex(tmp_path): + _write_codex_project(tmp_path) + + result = CliRunner().invoke(build_command, [str(tmp_path), "--mode", "code"]) + + assert result.exit_code != 0 + assert "ManagedRuntime" in result.output + + +def test_build_command_rejects_ks3_push_for_managed_runtime(tmp_path): + _write_codex_project(tmp_path) + + result = CliRunner().invoke(build_command, [str(tmp_path), "--push"]) + + assert result.exit_code != 0 + assert "不使用 KS3" in result.output diff --git a/tests/test_managed_runtime_native_smoke.py b/tests/test_managed_runtime_native_smoke.py new file mode 100644 index 00000000..4e5719a7 --- /dev/null +++ b/tests/test_managed_runtime_native_smoke.py @@ -0,0 +1,124 @@ +"""Cross-platform native Codex ManagedRuntime smoke test. + +This test is intentionally self-contained so CI can run the same contract on +macOS, Windows, and Linux without Docker. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from importlib.metadata import version +from pathlib import Path + +import pytest + + +def _unused_local_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _process_diagnostics(process: subprocess.Popen[str]) -> str: + if process.stdout is None: + return "" + return process.stdout.read().strip() + + +def _wait_for_health(port: int, process: subprocess.Popen[str]) -> dict: + deadline = time.monotonic() + 30 + url = f"http://127.0.0.1:{port}/health" + while time.monotonic() < deadline: + if process.poll() is not None: + details = _process_diagnostics(process) + pytest.fail( + "ksadk web exited before health check " + f"(code={process.returncode}): {details or '(no output)'}" + ) + try: + with urllib.request.urlopen(url, timeout=1) as response: # noqa: S310 + import json + + return json.loads(response.read()) + except (OSError, urllib.error.URLError): + time.sleep(0.25) + pytest.fail("ksadk web did not become healthy within 30 seconds") + + +def test_codex_native_binary_and_web_start_on_current_os(tmp_path: Path) -> None: + codex_cli_bin = pytest.importorskip( + "codex_cli_bin", + reason="install the codex extra to run native ManagedRuntime smoke", + ) + runtime_version = version("openai-codex") + codex_bin = codex_cli_bin.bundled_codex_path() + completed = subprocess.run( + [str(codex_bin), "--version"], + check=True, + capture_output=True, + text=True, + timeout=15, + ) + assert runtime_version in f"{completed.stdout}\n{completed.stderr}" + + (tmp_path / "agentengine.yaml").write_text( + ( + "name: native-codex-smoke\n" + 'version: "1.0.0"\n' + "framework: codex\n" + "artifact_type: ManagedRuntime\n" + "runtime:\n" + " name: codex\n" + f' version: "{runtime_version}"\n' + "model: gpt-5.1-codex\n" + "prompt: |\n" + " Native ManagedRuntime smoke test.\n" + ), + encoding="utf-8", + ) + port = _unused_local_port() + env = dict(os.environ) + env.update( + { + "OPENAI_API_KEY": "native-smoke-no-request", + "OPENAI_BASE_URL": "https://api.openai.com/v1", + "OPENAI_MODEL_NAME": "gpt-5.1-codex", + # Keep the spawned CLI's Chinese diagnostic output portable on + # non-interactive Windows runners as well as local terminals. + "PYTHONUTF8": "1", + } + ) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "ksadk.cli", + "web", + str(tmp_path), + "--port", + str(port), + "--no-open", + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + health = _wait_for_health(port, process) + assert health["status"] == "ok" + assert health["framework"] == "codex" + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=10) diff --git a/tests/test_managed_runtime_resolution.py b/tests/test_managed_runtime_resolution.py new file mode 100644 index 00000000..5c070f82 --- /dev/null +++ b/tests/test_managed_runtime_resolution.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import sys +import types + +import pytest + +from ksadk.managed_runtime import ( + ManagedRuntimeError, + ResolvedRuntime, + extract_bootstrap_runtime, + resolve_local_managed_runtime, + resolve_managed_runtime, + validate_installed_runtime, + validate_runtime_binary, +) + + +def test_extract_bootstrap_runtime_contract(): + resolved = extract_bootstrap_runtime( + { + "configs": { + "runtime.default_version": "0.144.4", + "runtime.package": "openai-codex==0.144.4", + } + }, + "codex", + ) + + assert resolved is not None + assert resolved.name == "codex" + assert resolved.version == "0.144.4" + assert resolved.source == "server" + assert resolved.package_requirement == "openai-codex==0.144.4" + + +@pytest.mark.asyncio +async def test_explicit_runtime_version_wins_without_server(): + resolved = await resolve_managed_runtime( + {"runtime": {"name": "codex", "version": "0.144.4"}}, + region="cn-beijing-6", + ) + + assert resolved.version == "0.144.4" + assert resolved.source == "manifest" + + +@pytest.mark.asyncio +async def test_unlocked_runtime_fails_when_server_has_no_default(): + with pytest.raises(ManagedRuntimeError, match="默认 Runtime 版本"): + await resolve_managed_runtime( + {"runtime": {"name": "codex"}}, + region="cn-beijing-6", + bootstrap={}, + ) + + +def test_validate_installed_runtime_rejects_version_mismatch(monkeypatch): + monkeypatch.setattr( + "ksadk.managed_runtime.installed_runtime_version", + lambda _name: "0.100.0", + ) + resolved = extract_bootstrap_runtime( + {"configs": {"runtime.default_version": "0.144.4"}}, + "codex", + ) + + assert resolved is not None + with pytest.raises(ManagedRuntimeError, match="0.100.0"): + validate_installed_runtime(resolved) + + +@pytest.mark.asyncio +async def test_local_runtime_uses_installed_version_when_offline(monkeypatch): + async def unavailable(*_args, **_kwargs): + raise ManagedRuntimeError("offline") + + monkeypatch.setattr("ksadk.managed_runtime.resolve_managed_runtime", unavailable) + monkeypatch.setattr( + "ksadk.managed_runtime.installed_runtime_version", + lambda _name: "0.144.4", + ) + monkeypatch.setattr( + "ksadk.managed_runtime.validate_runtime_binary", + lambda _resolved: "codex-cli 0.144.4", + ) + + resolved = await resolve_local_managed_runtime( + {"runtime": {"name": "codex"}}, + region="cn-beijing-6", + ) + + assert resolved.version == "0.144.4" + assert resolved.source == "installed-unlocked" + + +def test_validate_codex_binary_checks_effective_version(monkeypatch): + class Completed: + stdout = "codex-cli 0.144.4\n" + stderr = "" + + fake_module = types.ModuleType("codex_cli_bin") + fake_module.bundled_codex_path = lambda: "/native/platform/codex" + monkeypatch.setitem( + sys.modules, + "codex_cli_bin", + fake_module, + ) + monkeypatch.setattr( + "ksadk.managed_runtime.subprocess.run", + lambda *_args, **_kwargs: Completed(), + ) + + output = validate_runtime_binary( + ResolvedRuntime(name="codex", version="0.144.4", source="manifest") + ) + + assert output == "codex-cli 0.144.4" diff --git a/tests/test_markdown_repair.py b/tests/test_markdown_repair.py index 1fcd0073..798f14af 100644 --- a/tests/test_markdown_repair.py +++ b/tests/test_markdown_repair.py @@ -69,7 +69,7 @@ def test_repair_markdown_can_be_enabled_with_one_switch(): def test_repair_markdown_is_idempotent(): - raw = "标题\n```json\n{\"ok\": true}" + raw = '标题\n```json\n{"ok": true}' once = repair_markdown(raw, enabled=True) twice = repair_markdown(once, enabled=True) diff --git a/tests/test_open_source_audit.py b/tests/test_open_source_audit.py index 1b4a6477..e3dc54ca 100644 --- a/tests/test_open_source_audit.py +++ b/tests/test_open_source_audit.py @@ -5,7 +5,6 @@ import sys from pathlib import Path - SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "open_source_audit.py" @@ -256,7 +255,16 @@ def test_git_files_excludes_deleted_paths(tmp_path): audit.subprocess.run(["git", "init"], cwd=tmp_path, check=True, stdout=audit.subprocess.PIPE) audit.subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) audit.subprocess.run( - ["git", "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "init"], + [ + "git", + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test", + "commit", + "-m", + "init", + ], cwd=tmp_path, check=True, stdout=audit.subprocess.PIPE, @@ -272,6 +280,12 @@ def test_discover_files_falls_back_to_filesystem_for_non_git_directory(tmp_path) (tmp_path / "README.md").write_text("ok\n", encoding="utf-8") (tmp_path / "src" / "App.tsx").parent.mkdir(parents=True) (tmp_path / "src" / "App.tsx").write_text("ok\n", encoding="utf-8") + (tmp_path / ".venv" / "bin").mkdir(parents=True) + (tmp_path / ".venv" / "bin" / "python").write_text("generated\n", encoding="utf-8") + (tmp_path / ".cache" / "ksadk-web").mkdir(parents=True) + (tmp_path / ".cache" / "ksadk-web" / "bundle.tgz").write_text( + "generated\n", encoding="utf-8" + ) assert audit.discover_files(tmp_path) == ["README.md", "src/App.tsx"] @@ -283,9 +297,7 @@ def test_content_audit_blocks_private_doc_domains_and_secret_shapes(tmp_path): openai_key = "sk-" + "A" * 48 github_token = "ghp_" + "B" * 40 - (tmp_path / "README.md").write_text( - f"Docs: {private_docs_url}\n", encoding="utf-8" - ) + (tmp_path / "README.md").write_text(f"Docs: {private_docs_url}\n", encoding="utf-8") (tmp_path / "config.yml").write_text(f"AWS key {aws_access_key_id}\n", encoding="utf-8") (tmp_path / "llm.env").write_text(f"OPENAI_API_KEY={openai_key}\n", encoding="utf-8") (tmp_path / "repo.env").write_text(f"GITHUB_TOKEN={github_token}\n", encoding="utf-8") @@ -354,7 +366,7 @@ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): encoding="utf-8", ) (tmp_path / "cmd_create.py").write_text( - '# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:tag\n', + "# HERMES_IMAGE=hub.kce.ksyun.com/agentengine-public/hermes-agent:tag\n", encoding="utf-8", ) (tmp_path / "builder.py").write_text( @@ -378,7 +390,7 @@ def test_content_audit_allows_supported_internal_and_registry_paths(tmp_path): assert result.ok is False assert [(v.path, v.rule) for v in result.violations] == [ ("regional.py", "private-container-registry"), - ("other.py", "internal-service-endpoint") + ("other.py", "internal-service-endpoint"), ] diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index bca492e1..8ac51275 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -9,6 +9,7 @@ ROOT = Path(__file__).resolve().parents[1] DOCS_ROOT_URL = "https://kingsoftcloud.github.io/ksadk-python/" +DOCS_CONTENT_ROOT = ROOT / "docs-site" / "content" / "docs" ZH_DOC_URLS = { f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/quickstart/", f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/why-ksadk/", @@ -16,6 +17,10 @@ f"{DOCS_ROOT_URL}cn/docs/framework/getting-started/comparison/", f"{DOCS_ROOT_URL}cn/docs/framework/guides/observability-tracing/", f"{DOCS_ROOT_URL}cn/docs/framework/guides/cloud-deployment/", + f"{DOCS_ROOT_URL}cn/docs/framework/guides/hosted-ui-events/", + f"{DOCS_ROOT_URL}cn/docs/framework/guides/a2a-runtime/", + f"{DOCS_ROOT_URL}cn/docs/framework/guides/managed-runtime/", + f"{DOCS_ROOT_URL}cn/docs/framework/guides/harness-app/", } EN_DOC_URLS = { f"{DOCS_ROOT_URL}en/docs/framework/getting-started/quickstart/", @@ -24,8 +29,17 @@ f"{DOCS_ROOT_URL}en/docs/framework/getting-started/comparison/", f"{DOCS_ROOT_URL}en/docs/framework/guides/observability-tracing/", f"{DOCS_ROOT_URL}en/docs/framework/guides/cloud-deployment/", + f"{DOCS_ROOT_URL}en/docs/framework/guides/hosted-ui-events/", + f"{DOCS_ROOT_URL}en/docs/framework/guides/a2a-runtime/", + f"{DOCS_ROOT_URL}en/docs/framework/guides/managed-runtime/", + f"{DOCS_ROOT_URL}en/docs/framework/guides/harness-app/", } +_DOCS_LINK_PATTERN = re.compile( + r'(?:\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)|' + r']*?\bhref=["\']([^"\']+)["\'])' +) + def _read(relative_path: str) -> str: return (ROOT / relative_path).read_text(encoding="utf-8") @@ -35,6 +49,36 @@ def _github_pages_urls(markdown: str) -> set[str]: return set(re.findall(r"https://kingsoftcloud\.github\.io/ksadk-python/[^>\s)\"]*", markdown)) +def _docs_link_candidates(source: Path, target: str) -> tuple[Path, ...]: + """Return source files that can render an internal Fumadocs link. + + The check deliberately covers Markdown links and ```` entries: + static builds can render a page even when an in-content link would lead a + reader to a 404. External links and public assets are out of scope here. + """ + + path = target.split("#", 1)[0].split("?", 1)[0] + if not path or path.startswith(("http://", "https://", "mailto:", "/assets/")): + return () + + source_suffix = ".en.mdx" if source.name.endswith(".en.mdx") else ".mdx" + if path.startswith("/"): + segments = path.strip("/").split("/") + if len(segments) < 2 or segments[:2] not in (["cn", "docs"], ["en", "docs"]): + return () + locale_suffix = ".en.mdx" if segments[0] == "en" else ".mdx" + relative = segments[2:] + if relative == ["cli"]: + return (DOCS_CONTENT_ROOT / "cli" / f"index{locale_suffix}",) + base = DOCS_CONTENT_ROOT.joinpath(*relative) + return (base.with_suffix(locale_suffix), base / f"index{locale_suffix}") + + base = source.parent / path + if base.suffix: + return (base,) + return (base.with_suffix(source_suffix), base / f"index{source_suffix}") + + def test_public_readme_positions_ksadk_as_runtime_platform(): readme = _read("README.md") for expected in ( @@ -102,7 +146,6 @@ def test_public_readme_language_variants_keep_homepage_shape(): def test_public_readme_docs_links_match_fumadocs_routes(): - docs_site = ROOT / "docs-site" / "content" / "docs" checks = { "README.md": "cn", "README.zh-CN.md": "cn", @@ -121,11 +164,30 @@ def test_public_readme_docs_links_match_fumadocs_routes(): assert parts[1] == "docs" doc_segments = parts[2:] suffix = ".en.mdx" if expected_locale == "en" else ".mdx" - candidate = docs_site.joinpath(*doc_segments).with_suffix(suffix) - index_candidate = docs_site.joinpath(*doc_segments, f"index{suffix}") + candidate = DOCS_CONTENT_ROOT.joinpath(*doc_segments).with_suffix(suffix) + index_candidate = DOCS_CONTENT_ROOT.joinpath(*doc_segments, f"index{suffix}") assert candidate.exists() or index_candidate.exists(), url +def test_docs_internal_links_resolve_to_rendered_pages(): + broken: list[str] = [] + for source in sorted(DOCS_CONTENT_ROOT.rglob("*.mdx")): + text = source.read_text(encoding="utf-8") + for match in _DOCS_LINK_PATTERN.finditer(text): + target = next(value for value in match.groups() if value is not None) + candidates = _docs_link_candidates(source, target) + if candidates and not any(candidate.exists() for candidate in candidates): + display = " or ".join( + candidate.relative_to(DOCS_CONTENT_ROOT).as_posix() + for candidate in candidates + ) + broken.append( + f"{source.relative_to(DOCS_CONTENT_ROOT)} -> {target} ({display})" + ) + + assert not broken, "Broken internal documentation links:\n" + "\n".join(broken) + + def test_docs_site_cloud_deployment_guides_and_static_search_are_publicly_reachable(): docs_root = ROOT / "docs-site" search = _read("docs-site/components/search.tsx") @@ -161,8 +223,8 @@ def test_public_metadata_uses_runtime_platform_positioning(): init_text = _read("ksadk/__init__.py") version_text = _read("ksadk/version.py") - assert pyproject["project"]["version"] == "0.7.0" - assert 'VERSION = "0.7.0"' in version_text + assert pyproject["project"]["version"] == "0.8.0" + assert 'VERSION = "0.8.0"' in version_text assert "Agent Runtime Platform" in pyproject["project"]["description"] assert "Agent Runtime Platform" in init_text assert "Agent Development Kit" not in pyproject["project"]["description"] @@ -206,10 +268,10 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "workflow_dispatch:" in workflow assert "publish_target:" in workflow assert "alias-only" in workflow - assert 'default: "0.2.19"' in workflow + assert 'default: "0.3.0"' in workflow assert "approved_source_commit:" in workflow assert "Reviewed source commit SHA recorded in docs/maintainer-approval-record.md" in workflow - assert "KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.2.19' }}" in workflow + assert "KSADK_WEB_VERSION: ${{ github.event.inputs.ksadk_web_version || '0.3.0' }}" in workflow assert ( "KSADK_APPROVED_SOURCE_COMMIT: " "${{ github.event.inputs.approved_source_commit || " @@ -227,12 +289,14 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert "make public-test" in ci_workflow assert "tests/test_conversation_runtime.py" not in ci_workflow assert "tests/test_server_session_app.py" not in ci_workflow - assert 'KSADK_WEB_VERSION: "0.2.19"' in ci_workflow + assert 'KSADK_WEB_VERSION: "0.3.0"' in ci_workflow assert "PUBLIC_KSADK_WEB_VERSION" not in ci_workflow - assert "KSADK_WEB_VERSION ?= latest" in makefile + assert "KSADK_WEB_VERSION ?= 0.3.0" in makefile assert ( "PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py " - "tests/test_config_env_registry.py" in makefile + "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" in makefile ) assert "public-sync-ksadk-web-static: sync-ksadk-web-static" in makefile assert "python3 scripts/open_source_audit.py --target public-repo" in makefile @@ -243,6 +307,7 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert '--expected-current-commit "$${KSADK_APPROVED_SOURCE_COMMIT:-}"' not in makefile assert "KSADK_APPROVED_SOURCE_COMMIT is required" in makefile assert "public-build-check: clean-dist sync-ksadk-web-static" in makefile + assert "verify-ksadk-web-wheel-static" in makefile assert ( "public-preflight: public-version-gate public-audit sync-ksadk-web-static " "public-test docs-site-build public-build-check" in makefile @@ -270,11 +335,11 @@ def test_public_ci_runs_gitleaks_and_documents_branch_protection(): assert "Branch protection and publish environment are configured" in approval_record -def test_public_release_approval_template_tracks_current_version(): +def test_public_release_candidate_tracks_current_version(): approval_record = _read("docs/maintainer-approval-record.md") - assert "| Python package version | 0.7.0 |" in approval_record - assert "make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.7.0" in approval_record + assert "| Python package version | 0.8.0 |" in approval_record + assert "make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.8.0" in approval_record def test_public_release_sync_compares_exported_file_contents(): diff --git a/tests/test_runtime_common_packaging.py b/tests/test_runtime_common_packaging.py index 9402d97b..84f681c8 100644 --- a/tests/test_runtime_common_packaging.py +++ b/tests/test_runtime_common_packaging.py @@ -1,7 +1,16 @@ -from pathlib import Path +import importlib +import sys import zipfile -import tomllib +from pathlib import Path + +if sys.version_info >= (3, 11): + tomllib = importlib.import_module("tomllib") +else: + tomllib = importlib.import_module("tomli") +from ksadk.builders.code_builder import CodeBuilder +from ksadk.builders.container_builder import ContainerBuilder +from ksadk.detection import DetectionResult, FrameworkType REPO_ROOT = Path(__file__).resolve().parents[1] @@ -14,9 +23,9 @@ def test_pyproject_uses_in_repo_runtime_common_source_package(): def test_runtime_common_workspace_router_is_python310_compatible(tmp_path: Path): - router_source = (REPO_ROOT / "ksadk_runtime_common" / "workspace_files" / "router.py").read_text( - encoding="utf-8" - ) + router_source = ( + REPO_ROOT / "ksadk_runtime_common" / "workspace_files" / "router.py" + ).read_text(encoding="utf-8") assert "from datetime import UTC" not in router_source assert "datetime.UTC" not in router_source @@ -74,11 +83,7 @@ def test_built_wheel_excludes_legacy_web_ui_sources_and_build_outputs(): assert wheels, "请先运行 uv build 生成 dist/ksadk-*.whl" with zipfile.ZipFile(wheels[-1]) as archive: - leaked = [ - name - for name in archive.namelist() - if name.startswith("ksadk/server/web-ui/") - ] + leaked = [name for name in archive.namelist() if name.startswith("ksadk/server/web-ui/")] assert leaked == [] @@ -132,10 +137,12 @@ def test_pyproject_declares_validated_framework_dependency_windows(): pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") assert "fastapi>=0.100.0,<1.0.0" in pyproject - assert "google-adk>=1.34.0,<2.0.0" in pyproject - assert "langchain>=1.3.0,<2.0.0" in pyproject - assert "langchain-core>=1.4.0,<2.0.0" in pyproject - assert "langchain-openai>=1.2.0,<2.0.0" in pyproject + # goal-00: ADK 窗口放宽为 1.34.x 至 <3.0(支持 1.x 与 2.x) + assert "google-adk>=1.34.0,<3.0.0" in pyproject + # LangChain 生态下限锚定本地已验证版本(不降级,<2.0 守 1.x 稳定线) + assert "langchain>=1.3.14,<2.0.0" in pyproject + assert "langchain-core>=1.5.0,<2.0.0" in pyproject + assert "langchain-openai>=1.4.0,<2.0.0" in pyproject assert "langgraph>=1.2.0,<1.3.0" in pyproject assert "deepagents>=0.6.2,<1.0.0" in pyproject assert "fastapi>=0.100.0,<0.124.0" not in pyproject @@ -152,14 +159,27 @@ def test_makefile_delegates_runtime_image_builds_to_agentengine_images_repo(): makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8") assert "AGENTENGINE_IMAGES_DIR ?= ../agentengine-images" in makefile - assert "$(MAKE) -C \"$(AGENTENGINE_IMAGES_DIR)\" $@" in makefile + assert '$(MAKE) -C "$(AGENTENGINE_IMAGES_DIR)" $@' in makefile assert "-f deploy/openclaw/Dockerfile" not in makefile assert "-f deploy/hermes/Dockerfile" not in makefile -def test_runtime_templates_initialize_tracing_for_otlp_envs(): - for rel_path in ["ksadk/builders/code_builder.py", "ksadk/builders/container_builder.py"]: - source = (REPO_ROOT / rel_path).read_text(encoding="utf-8") +def test_runtime_templates_initialize_tracing_for_otlp_envs(tmp_path: Path): + detection_result = DetectionResult( + type=FrameworkType.LANGGRAPH, + name="demo_agent", + entry_point="demo_agent/agent.py", + package_path=str(tmp_path / "demo_agent"), + agent_variable="root_agent", + ) + templates = [ + CodeBuilder(tmp_path)._generate_entrypoint(detection_result), + ContainerBuilder(tmp_path)._generate_entrypoint(detection_result, "demo_agent"), + ] + + for source in templates: + compile(source, "entrypoint.py", "exec") + normalized_source = " ".join(source.split()) assert "OTEL_EXPORTER_OTLP_ENDPOINT" in source assert "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" in source @@ -169,5 +189,8 @@ def test_runtime_templates_initialize_tracing_for_otlp_envs(): assert "CLOUD_MONITOR_LANGFUSE_PUBLIC_KEY" in source assert "CLOUD_MONITOR_LANGFUSE_SECRET_KEY" in source assert "CLOUD_MONITOR_LANGFUSE_HOST" in source - assert 'os.environ.get("LANGFUSE_PUBLIC_KEY") or has_otlp' in source - assert "or has_cloud_monitor_otlp or has_cloud_monitor_langfuse" in source + assert ( + 'os.environ.get("LANGFUSE_PUBLIC_KEY") or has_otlp ' + "or has_cloud_monitor_otlp or has_cloud_monitor_langfuse" + ) in normalized_source + assert "setup_tracing(use_callback_only=use_callback_only)" in source diff --git a/tests/test_security_boundaries.py b/tests/test_security_boundaries.py new file mode 100644 index 00000000..ff8e2fb1 --- /dev/null +++ b/tests/test_security_boundaries.py @@ -0,0 +1,77 @@ +"""Regression tests for release security boundaries exposed by CodeQL.""" + +import importlib +from pathlib import Path + +from ksadk.builders.container_builder import ContainerBuilder +from ksadk.model_proxy.cache import CapabilityCache, credential_scope +from ksadk.model_proxy.detect import ModelCapabilities +from ksadk.server.routes.common import _find_ui_static_asset +from ksadk.skills.service_client import SkillServiceClient + + +def test_kop_signing_requires_exact_tls_host(): + trusted = SkillServiceClient(base_url="https://aicp.api.ksyun.com/v1") + spoofed = SkillServiceClient(base_url="https://aicp.api.ksyun.com.attacker.example/v1") + plaintext = SkillServiceClient(base_url="http://aicp.api.ksyun.com/v1") + + assert trusted._is_kop_mode() + assert not spoofed._is_kop_mode() + assert not plaintext._is_kop_mode() + + +def test_kcr_optimization_rewrites_only_an_exact_registry_host(monkeypatch, tmp_path: Path): + settings_module = importlib.import_module("ksadk.configs.settings") + monkeypatch.setattr(settings_module, "check_endpoint_reachable", lambda *args, **kwargs: True) + builder = ContainerBuilder(tmp_path) + + assert ( + builder._optimize_kcr_endpoint("hub.kce.ksyun.com/agentengine/demo:latest") + == "hub-vpc.kce.ksyun.com/agentengine/demo:latest" + ) + assert ( + builder._optimize_kcr_endpoint("registry.example/hub.kce.ksyun.com/demo:latest") + == "registry.example/hub.kce.ksyun.com/demo:latest" + ) + assert ( + builder._optimize_kcr_endpoint("hub.kce.ksyun.com.attacker.example/demo:latest") + == "hub.kce.ksyun.com.attacker.example/demo:latest" + ) + + +def test_static_asset_lookup_cannot_escape_the_configured_bundle(tmp_path: Path): + bundle = tmp_path / "bundle" + asset = bundle / "assets" / "app.js" + asset.parent.mkdir(parents=True) + asset.write_text("console.log('ok')", encoding="utf-8") + outside = tmp_path / "outside.txt" + outside.write_text("private", encoding="utf-8") + + assert _find_ui_static_asset(bundle, "assets/app.js") == asset.resolve() + assert _find_ui_static_asset(bundle, "../outside.txt") is None + assert _find_ui_static_asset(bundle, "assets/../../outside.txt") is None + + +def test_credential_cache_scope_is_domain_separated_and_nonreversible(): + first = credential_scope("model-key-one") + second = credential_scope("model-key-two") + + assert first != second + assert len(first) == 32 + assert "model-key" not in first + + +def test_capability_cache_uses_a_prederived_scope_without_retaining_credentials(): + cache = CapabilityCache() + calls: list[tuple[str, str]] = [] + + def probe(model: str, base: str) -> ModelCapabilities: + calls.append((model, base)) + return ModelCapabilities(verdict="unsupported") + + scope = credential_scope("model-key") + first = cache.get_or_probe("glm-5.2", "https://gateway.example/v1", scope, probe) + second = cache.get_or_probe("glm-5.2", "https://gateway.example/v1", scope, probe) + assert first.verdict == "unsupported" + assert second.verdict == "unsupported" + assert calls == [("glm-5.2", "https://gateway.example/v1")] diff --git a/tests/test_tracing_setup_otlp.py b/tests/test_tracing_setup_otlp.py index 81781961..7bfdfdba 100644 --- a/tests/test_tracing_setup_otlp.py +++ b/tests/test_tracing_setup_otlp.py @@ -5,6 +5,8 @@ import types import pytest +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult @pytest.fixture(autouse=True) @@ -72,7 +74,7 @@ def __init__(self, exporter, **kwargs): class _FakeHttpOTLPSpanExporter: - instances = [] + instances: list["_FakeHttpOTLPSpanExporter"] = [] def __init__(self, *, endpoint, headers=None, **kwargs): self.endpoint = endpoint @@ -112,14 +114,19 @@ def _install_fake_otel(monkeypatch): monkeypatch.setitem( sys.modules, "opentelemetry.sdk.trace", - types.SimpleNamespace(TracerProvider=_FakeTracerProvider), + types.SimpleNamespace( + ReadableSpan=ReadableSpan, + TracerProvider=_FakeTracerProvider, + ), ) monkeypatch.setitem( sys.modules, "opentelemetry.sdk.trace.export", types.SimpleNamespace( - SimpleSpanProcessor=_FakeSimpleSpanProcessor, BatchSpanProcessor=_FakeBatchSpanProcessor, + SimpleSpanProcessor=_FakeSimpleSpanProcessor, + SpanExporter=SpanExporter, + SpanExportResult=SpanExportResult, ), ) monkeypatch.setitem( @@ -260,7 +267,9 @@ def test_generic_otlp_langfuse_endpoint_adds_auth_from_langfuse_env(monkeypatch) "x-langfuse-ingestion-version": "4", } assert len(trace_api.provider.processors) == 1 - assert trace_api.provider.processors[0].exporter._span_transform is setup._prepare_langfuse_spans + assert ( + trace_api.provider.processors[0].exporter._span_transform is setup._prepare_langfuse_spans + ) def test_generic_otlp_langfuse_endpoint_keeps_existing_authorization(monkeypatch): @@ -294,7 +303,9 @@ def test_generic_otlp_langfuse_endpoint_keeps_existing_authorization(monkeypatch def test_otlp_authorization_header_accepts_plus_between_scheme_and_value(monkeypatch): _install_fake_otel(monkeypatch) - monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "https://collector.example.com/v1/traces") + monkeypatch.setenv( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "https://collector.example.com/v1/traces" + ) monkeypatch.setenv( "OTEL_EXPORTER_OTLP_TRACES_HEADERS", "Authorization=Basic+cGs6c2s=,x-langfuse-ingestion-version=4", @@ -456,7 +467,10 @@ def test_cloud_monitor_langfuse_sdk_config_keeps_otlp_by_default(monkeypatch, ca ) assert len(_FakeHttpOTLPSpanExporter.instances) == 1 - assert _FakeHttpOTLPSpanExporter.instances[0].endpoint == "https://cn-beijing-6.otlp.ksyun.com:4318/v1/traces" + assert ( + _FakeHttpOTLPSpanExporter.instances[0].endpoint + == "https://cn-beijing-6.otlp.ksyun.com:4318/v1/traces" + ) assert len(trace_api.provider.processors) == 1 assert "CloudMonitor OTLP exporter enabled" in caplog.text @@ -505,7 +519,10 @@ def test_cloud_monitor_otlp_can_be_forced_with_langfuse_sdk_config(monkeypatch): ) assert len(_FakeHttpOTLPSpanExporter.instances) == 1 - assert _FakeHttpOTLPSpanExporter.instances[0].endpoint == "https://cn-beijing-6.otlp.ksyun.com:4318/v1/traces" + assert ( + _FakeHttpOTLPSpanExporter.instances[0].endpoint + == "https://cn-beijing-6.otlp.ksyun.com:4318/v1/traces" + ) def test_cloud_monitor_exporter_logs_export_result(monkeypatch, caplog): @@ -726,6 +743,5 @@ def test_langfuse_callback_only_skips_generic_otlp_to_same_langfuse_host(monkeyp assert _FakeHttpOTLPSpanExporter.instances == [] assert trace_api.provider.processors == [] assert ( - "Generic OTLP HTTP exporter skipped because LANGFUSE_USE_CALLBACK is enabled" - in caplog.text + "Generic OTLP HTTP exporter skipped because LANGFUSE_USE_CALLBACK is enabled" in caplog.text ) diff --git a/uv.lock b/uv.lock index 53f1ed9d..e7e13705 100644 --- a/uv.lock +++ b/uv.lock @@ -10,26 +10,90 @@ resolution-markers = [ [[package]] name = "a2a-sdk" -version = "0.3.25" +version = "1.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, { name = "google-api-core" }, + { name = "googleapis-common-protos" }, { name = "httpx" }, { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, { name = "protobuf" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/83/3c99b276d09656cce039464509f05bf385e5600d6dc046a131bbcf686930/a2a_sdk-0.3.25.tar.gz", hash = "sha256:afda85bab8d6af0c5d15e82f326c94190f6be8a901ce562d045a338b7127242f", size = 270638, upload-time = "2026-03-10T13:08:46.417Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/7e/8ac10bbf8b15b16574355f39b17dbdf617a282c27b41c7ff2116e30336df/a2a_sdk-1.1.0.tar.gz", hash = "sha256:e8102dad1b36709dbdc3d19319e38e6dfa3b3a79c30416030eb2d482576be204", size = 375726, upload-time = "2026-05-29T09:34:43.015Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/f9/6a62520b7ecb945188a6e1192275f4732ff9341cd4629bc975a6c146aeab/a2a_sdk-0.3.25-py3-none-any.whl", hash = "sha256:2fce38faea82eb0b6f9f9c2bcf761b0d78612c80ef0e599b50d566db1b2654b5", size = 149609, upload-time = "2026-03-10T13:08:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ea/3a5b160cfd51c67759b08748051094d9365ceff18127633d0021950c9860/a2a_sdk-1.1.0-py3-none-any.whl", hash = "sha256:d7f5846caf18033d8bf3108b11ec827dd8dd32f867c98848ede0e39474be93be", size = 241886, upload-time = "2026-05-29T09:34:41.484Z" }, ] [package.optional-dependencies] -http-server = [ +fastapi = [ { name = "fastapi" }, { name = "sse-starlette" }, { name = "starlette" }, ] +postgresql = [ + { name = "sqlalchemy", extra = ["asyncio", "postgresql-asyncpg"] }, +] + +[[package]] +name = "a2ui-core" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/51/477ea2d173f77d54f1a3b963fafc811a228427d077442bc7b6cc7409787d/a2ui_core-0.1.1.tar.gz", hash = "sha256:591df9357cd84a3ccf409132a741eb1fbb060be73de0eebb3dcfdd4c7b33180d", size = 84241, upload-time = "2026-07-08T03:49:36.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/14/2344f6edc4b381db477d4387a28d1c84a56614962cc16f55074be981a12a/a2ui_core-0.1.1-py3-none-any.whl", hash = "sha256:8857e1942b8b1bab530efa0c20f6f9048c5771b47b3b98b3bc1ca28d39480069", size = 75389, upload-time = "2026-07-08T03:49:35.698Z" }, +] + +[[package]] +name = "ag-ui-a2ui-toolkit" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/ce/85f3960a83d962e5690bc0f27a3baf3bf1602edc2b0603085928c964ea14/ag_ui_a2ui_toolkit-0.0.4.tar.gz", hash = "sha256:172e2724e53df8173685a3fb896a6e5175eea06e1dc166c715db110ba4beba76", size = 18960, upload-time = "2026-06-17T13:34:28.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/7a/acf85b01cd996bd011b71e181fd9f3daff5396fc3b7d78ba9445bfc08ecf/ag_ui_a2ui_toolkit-0.0.4-py3-none-any.whl", hash = "sha256:236fc511e1ec2399bcda0c14a109b3fb0a0c3e3988c18ef1918745b1c1535e30", size = 21315, upload-time = "2026-06-17T13:34:29.505Z" }, +] + +[[package]] +name = "ag-ui-langgraph" +version = "0.0.42" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ag-ui-a2ui-toolkit" }, + { name = "ag-ui-protocol" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/d5/648338b5ff8f90c488217297a8656b6c133a251a39af2d62bada142b7819/ag_ui_langgraph-0.0.42.tar.gz", hash = "sha256:5384647b9b7b098189530c59b26741581dd009c8dfda907fbfaa9bafa8d21249", size = 330419, upload-time = "2026-06-19T15:07:01.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/c0/32fea8de7ac50a90ea150f999318f4d121cd22d58e446c8fdb420fc2a11e/ag_ui_langgraph-0.0.42-py3-none-any.whl", hash = "sha256:4fd19f0da6d0e16d727ec89e99b692916cbba2b0302f96aba2497043cbfec5db", size = 37969, upload-time = "2026-06-19T15:07:00.517Z" }, +] + +[package.optional-dependencies] +fastapi = [ + { name = "fastapi" }, +] + +[[package]] +name = "ag-ui-protocol" +version = "0.1.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, +] [[package]] name = "aiofiles" @@ -51,7 +115,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -61,112 +125,143 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, - { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, - { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, - { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, - { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, - { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, - { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, - { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4f/2c41f800a0b560785c10fb316216ac058c105f9be50bdc6a285de88db625/aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd", size = 434053, upload-time = "2026-01-03T17:29:40.074Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/29cd63c7ecfdb65ccc12f7d808cac4fa2a19544660c06c61a4a48462de0c/aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c", size = 456687, upload-time = "2026-01-03T17:29:41.819Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, - { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, - { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, - { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, - { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, - { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, - { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, - { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/4d/4a99fb425c5e0cad715eea7bd190aff46f38b959a0a2dadb993705d34b26/aiohttp-3.14.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b", size = 765848, upload-time = "2026-07-23T01:52:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/43b85dc55b8e950dc644babe762add781319ea881b57b33d2cce12017d12/aiohttp-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a", size = 517476, upload-time = "2026-07-23T01:52:10.846Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9e/73b582c4dbbc3c12ef4473822475effaabf1f934b56f14f5b03fe5d3a2af/aiohttp-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5", size = 515334, upload-time = "2026-07-23T01:52:12.636Z" }, + { url = "https://files.pythonhosted.org/packages/79/03/e98c3c9e05a5bdf97defe5ff9169baba4f0ec9a901f2d60e0f060c2f051e/aiohttp-3.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f", size = 1708830, upload-time = "2026-07-23T01:52:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2c/26e60b694844dfd2176c57f913a22d0cd6a16f9ff202cbda7580d0328b98/aiohttp-3.14.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43", size = 1674012, upload-time = "2026-07-23T01:52:16.486Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/672df92e3172cd876aacfa97a952ac560877eb169384b2991ac5b273de4c/aiohttp-3.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9", size = 1767015, upload-time = "2026-07-23T01:52:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c5/228dec7bfec1c373cc2217cdeb47d6456dcd7a13a4c55144930a75ae3851/aiohttp-3.14.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8", size = 1858700, upload-time = "2026-07-23T01:52:20.08Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ff/cb36724e8c8d17f90ada567a9ff3efe1d6e9b549fba697a242aece180f21/aiohttp-3.14.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479", size = 1714075, upload-time = "2026-07-23T01:52:22.071Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3a/296a4135c6366376263aeef54b15caca1f07676c2ae0c525d7832f2f808a/aiohttp-3.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b", size = 1588234, upload-time = "2026-07-23T01:52:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/7d/81/9d5d853ef892dc066d1eb6db0e87a47348b920c1c879aa554612fdbd9d79/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d", size = 1677300, upload-time = "2026-07-23T01:52:25.861Z" }, + { url = "https://files.pythonhosted.org/packages/68/96/021d386ae32d9b26d4b88df2e794546232ff56bb6be952bf6be227c0bbc7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d", size = 1691501, upload-time = "2026-07-23T01:52:28Z" }, + { url = "https://files.pythonhosted.org/packages/29/9f/af66adce26a14af135c003cbd0f44ccaa68cebd30ff8ac99ca47fb4958f7/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2", size = 1735113, upload-time = "2026-07-23T01:52:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/28c390d4c9851effe52ac25b5a2e1d92246acd00728b4fc7975dafb67484/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48", size = 1577486, upload-time = "2026-07-23T01:52:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/db/c2/00e23a1bf2abb70dd353f6987db7e7f2491d0261f7363997738c71c98f95/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f", size = 1751353, upload-time = "2026-07-23T01:52:33.688Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7d/d51a706a8cbfa57f0611127daf61ab3ae02ab8420b0407412079227d1c65/aiohttp-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32", size = 1698681, upload-time = "2026-07-23T01:52:38.167Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/90bd5cd9fdd9787cb4211d284d1fb8401339a933cb0227a15b71e789232f/aiohttp-3.14.3-cp310-cp310-win32.whl", hash = "sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e", size = 456733, upload-time = "2026-07-23T01:52:41.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/15/fe5b8f6a71ae112bc677163d0b0701bda5dc15005249582258ede0eb88c7/aiohttp-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c", size = 480460, upload-time = "2026-07-23T01:52:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/00/45e98b6645cd7f00a4b78b749ebd309094b0eaeb2d2e96157eadbc0d0050/aiohttp-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb", size = 453479, upload-time = "2026-07-23T01:52:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiologic" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/d3/2d310b1b839014034dba0cba685e492df8a5c7ad32c19cab7e979eed6554/aiologic-0.17.1-py3-none-any.whl", hash = "sha256:c66b319830fedb7ca3d2b2125fa6f5b653f89418c2a27ea76f259ec5f00943c0", size = 161331, upload-time = "2026-06-27T20:41:31.877Z" }, ] [[package]] @@ -191,21 +286,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] -[[package]] -name = "alembic" -version = "1.18.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -346,6 +426,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -706,6 +795,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "copilotkit" +version = "0.1.94" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ag-ui-langgraph", extra = ["fastapi"] }, + { name = "ag-ui-protocol" }, + { name = "fastapi" }, + { name = "langchain" }, + { name = "langgraph" }, + { name = "partialjson" }, + { name = "pydantic-core" }, + { name = "toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/e3/d40a7f8698e29efaa5eeb2064a68c1d8a51f1346390021d5cc76fc08d8a2/copilotkit-0.1.94.tar.gz", hash = "sha256:a60a56aeba8127f3eb382337e5799a9bc4b31fb506bc308045c0050abc6a4488", size = 49487, upload-time = "2026-06-04T17:41:36.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/48/b22f4cbd7f8a6e2f3e80260e6aef06d845fa38f469ac4dd1bf2e14e44766/copilotkit-0.1.94-py3-none-any.whl", hash = "sha256:424e657118bebc055d5f89d1e93290773ff33c05860a3da242653ed8446affa8", size = 58434, upload-time = "2026-06-04T17:41:35.479Z" }, +] + [[package]] name = "crcmod" version = "1.7" @@ -780,6 +888,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47", size = 21811, upload-time = "2025-12-31T23:15:37.189Z" }, +] + [[package]] name = "cyclopts" version = "4.16.0" @@ -1201,543 +1322,79 @@ wheels = [ [[package]] name = "google-adk" -version = "1.34.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, - { name = "anyio" }, { name = "authlib" }, { name = "click" }, { name = "fastapi" }, - { name = "google-api-python-client" }, { name = "google-auth", extra = ["pyopenssl"] }, - { name = "google-cloud-aiplatform", extra = ["agent-engines"] }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-bigquery-storage" }, - { name = "google-cloud-bigtable" }, - { name = "google-cloud-dataplex" }, - { name = "google-cloud-discoveryengine" }, - { name = "google-cloud-pubsub" }, - { name = "google-cloud-secret-manager" }, - { name = "google-cloud-spanner" }, - { name = "google-cloud-speech" }, - { name = "google-cloud-storage", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "google-cloud-storage", version = "3.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, { name = "google-genai" }, { name = "graphviz" }, { name = "httpx" }, { name = "jsonschema" }, - { name = "mcp" }, { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-gcp-logging" }, - { name = "opentelemetry-exporter-gcp-monitoring" }, - { name = "opentelemetry-exporter-gcp-trace" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk" }, - { name = "pyarrow" }, + { name = "packaging" }, { name = "pydantic" }, - { name = "python-dateutil" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, - { name = "sqlalchemy-spanner" }, - { name = "starlette" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "tzlocal" }, - { name = "uvicorn" }, - { name = "watchdog" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/dc/1fec30bdc7b640087ee64dfc69bae4450ef43a1cba688bd9cb99dca6ba67/google_adk-1.34.0.tar.gz", hash = "sha256:bce56f6f8f400dcc83d633190f5f44d27fcb88a515a9556aeeb9ef9cbc896600", size = 2431386, upload-time = "2026-05-18T23:48:00.212Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/86/7d9e222e1ee6dd9719844d5fad4ba25c9ec8c4191ee2644a81bd04749c6b/google_adk-1.34.0-py3-none-any.whl", hash = "sha256:928340bc4b6bd3de8bfa780d3971d4c1bb10e48a52fb66875f273ba42d72b669", size = 2876260, upload-time = "2026-05-18T23:48:02.296Z" }, -] - -[[package]] -name = "google-api-core" -version = "2.30.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, -] - -[[package]] -name = "google-api-python-client" -version = "2.193.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-auth-httplib2" }, - { name = "httplib2" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006, upload-time = "2026-03-17T18:25:29.415Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490, upload-time = "2026-03-17T18:25:26.608Z" }, -] - -[[package]] -name = "google-auth" -version = "2.49.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, -] - -[package.optional-dependencies] -pyopenssl = [ - { name = "pyopenssl" }, -] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-auth-httplib2" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "httplib2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, -] - -[[package]] -name = "google-cloud-aiplatform" -version = "1.153.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-resource-manager" }, - { name = "google-cloud-storage", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, - { name = "google-cloud-storage", version = "3.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "google-genai" }, - { name = "packaging" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/97/1779e66ab845550bc602364311ea093ba156cb805a1c31b7c4d6f25b5863/google_cloud_aiplatform-1.153.1.tar.gz", hash = "sha256:445b6c683d5c630f174d81ae1f69f7da9e27e4d4ec5b70c5fe96de5c1247cfbc", size = 11011349, upload-time = "2026-05-15T06:34:14.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/01/8a1900e7a742ed480e6037ac4f6541466cb981d81bd4cbd34a9d46204ea1/google_cloud_aiplatform-1.153.1-py2.py3-none-any.whl", hash = "sha256:033fa1595a7e8ed1d97066e261e630f38fbc60e10c98c6487cf228fe9c7ec151", size = 9170782, upload-time = "2026-05-15T06:34:10.887Z" }, -] - -[package.optional-dependencies] -agent-engines = [ - { name = "aiohttp" }, - { name = "cloudpickle" }, - { name = "google-cloud-iam" }, - { name = "google-cloud-logging" }, - { name = "google-cloud-trace" }, - { name = "opentelemetry-exporter-gcp-logging" }, - { name = "opentelemetry-exporter-gcp-trace" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] - -[[package]] -name = "google-cloud-appengine-logging" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/65/38/89317773c64b5a7e9b56b9aecb2e39ac02d8d6d09fb5b276710c6892e690/google_cloud_appengine_logging-1.8.0.tar.gz", hash = "sha256:84b705a69e4109fc2f68dfe36ce3df6a34d5c3d989eee6d0ac1b024dda0ba6f5", size = 18071, upload-time = "2026-01-15T13:14:40.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/66/4a9be8afb1d0bf49472478cec20fefe4f4cb3a6e67be2231f097041e7339/google_cloud_appengine_logging-1.8.0-py3-none-any.whl", hash = "sha256:a4ce9ce94a9fd8c89ed07fa0b06fcf9ea3642f9532a1be1a8c7b5f82c0a70ec6", size = 18380, upload-time = "2026-01-09T14:52:58.154Z" }, -] - -[[package]] -name = "google-cloud-audit-log" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/d2/ad96950410f8a05e921a6da2e1a6ba4aeca674bbb5dda8200c3c7296d7ad/google_cloud_audit_log-0.4.0.tar.gz", hash = "sha256:8467d4dcca9f3e6160520c24d71592e49e874838f174762272ec10e7950b6feb", size = 44682, upload-time = "2025-10-17T02:33:44.641Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/25/532886995f11102ad6de290496de5db227bd3a73827702445928ad32edcb/google_cloud_audit_log-0.4.0-py3-none-any.whl", hash = "sha256:6b88e2349df45f8f4cc0993b687109b1388da1571c502dc1417efa4b66ec55e0", size = 44890, upload-time = "2025-10-17T02:30:55.11Z" }, -] - -[[package]] -name = "google-cloud-bigquery" -version = "3.40.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-resumable-media" }, - { name = "packaging" }, - { name = "python-dateutil" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/0c/153ee546c288949fcc6794d58811ab5420f3ecad5fa7f9e73f78d9512a6e/google_cloud_bigquery-3.40.1.tar.gz", hash = "sha256:75afcfb6e007238fe1deefb2182105249321145ff921784fe7b1de2b4ba24506", size = 511761, upload-time = "2026-02-12T18:44:18.958Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/f5/081cf5b90adfe524ae0d671781b0d497a75a0f2601d075af518828e22d8f/google_cloud_bigquery-3.40.1-py3-none-any.whl", hash = "sha256:9082a6b8193aba87bed6a2c79cf1152b524c99bb7e7ac33a785e333c09eac868", size = 262018, upload-time = "2026-02-12T18:44:16.913Z" }, -] - -[[package]] -name = "google-cloud-bigquery-storage" -version = "2.36.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/fa/877e0059349369be38a64586b135c59ceadb87d0386084043d8c440ef929/google_cloud_bigquery_storage-2.36.2.tar.gz", hash = "sha256:ad49d8c09ad6cd82da4efe596fcfcdbc1458bf05b93915e3c5c00f1e700ae128", size = 308672, upload-time = "2026-02-19T16:03:10.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/07/62dbe78ef773569be0a1d2c1b845e9214889b404e506126519b4d33ee999/google_cloud_bigquery_storage-2.36.2-py3-none-any.whl", hash = "sha256:823a73db0c4564e8ad3eedcfd5049f3d5aa41775267863b5627211ec36be2dbf", size = 304398, upload-time = "2026-02-19T16:02:55.112Z" }, -] - -[[package]] -name = "google-cloud-bigtable" -version = "2.35.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "grpc-google-iam-v1" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/c9/aceae21411b1a77fb4d3cde6e6f461321ee33c65fb8dc53480d4e47e1a55/google_cloud_bigtable-2.35.0.tar.gz", hash = "sha256:f5699012c5fea4bd4bdf7e80e5e3a812a847eb8f41bf8dc2f43095d6d876b83b", size = 775613, upload-time = "2025-12-17T15:18:14.303Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/69/03eed134d71f6117ffd9efac2d1033bb2fa2522e9e82545a0828061d32f4/google_cloud_bigtable-2.35.0-py3-none-any.whl", hash = "sha256:f355bfce1f239453ec2bb3839b0f4f9937cf34ef06ef29e1ca63d58fd38d0c50", size = 540341, upload-time = "2025-12-17T15:18:12.176Z" }, -] - -[[package]] -name = "google-cloud-core" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, -] - -[[package]] -name = "google-cloud-dataplex" -version = "2.19.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/fb/0ff732c12b394b13c13a094f15f25b0a8752c00aad03c29e1d24d2e60ec5/google_cloud_dataplex-2.19.0.tar.gz", hash = "sha256:81a637f2474bd5391ed102311ac6b90d6c066fae1c29837baa8dd36328db0b05", size = 882730, upload-time = "2026-05-07T08:04:07.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/67/b880c7c96f961ef945f992e45eaeb727a6a54586eb5e0d28f2677958d413/google_cloud_dataplex-2.19.0-py3-none-any.whl", hash = "sha256:356204387ea954710519946dbca224a16f36fb81bd651ccb92ef2bcdbb12422e", size = 676716, upload-time = "2026-05-07T08:02:39.806Z" }, -] - -[[package]] -name = "google-cloud-discoveryengine" -version = "0.13.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/cd/b33bbc4b096d937abee5ebfad3908b2bdc65acd1582191aa33beaa2b70a5/google_cloud_discoveryengine-0.13.12.tar.gz", hash = "sha256:d6b9f8fadd8ad0d2f4438231c5eb7772a317e9f59cafbcbadc19b5d54c609419", size = 3582382, upload-time = "2025-09-22T16:51:14.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/70/607f6011648f603d35e60a16c34aee68a0b39510e4268d4859f3268684f9/google_cloud_discoveryengine-0.13.12-py3-none-any.whl", hash = "sha256:295f8c6df3fb26b90fb82c2cd6fbcf4b477661addcb19a94eea16463a5c4e041", size = 3337248, upload-time = "2025-09-22T16:50:57.375Z" }, -] - -[[package]] -name = "google-cloud-iam" -version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/0b/037b1e1eb601646d6f49bc06d62094c1d0996b373dcbf70c426c6c51572e/google_cloud_iam-2.21.0.tar.gz", hash = "sha256:fc560527e22b97c6cbfba0797d867cf956c727ba687b586b9aa44d78e92281a3", size = 499038, upload-time = "2026-01-15T13:15:08.243Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/44/02ac4e147ea034a3d641c11b54c9d8d0b80fc1ea6a8b7d6c1588d208d42a/google_cloud_iam-2.21.0-py3-none-any.whl", hash = "sha256:1b4a21302b186a31f3a516ccff303779638308b7c801fb61a2406b6a0c6293c4", size = 458958, upload-time = "2026-01-15T13:13:40.671Z" }, -] - -[[package]] -name = "google-cloud-logging" -version = "3.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-appengine-logging" }, - { name = "google-cloud-audit-log" }, - { name = "google-cloud-core" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/ce/0d3539008dc33b436e7c5c644abc8f8a7ec5900911d14a8e34e145f0ebe5/google_cloud_logging-3.14.0.tar.gz", hash = "sha256:361e83cd692fecc7da10351f641c474591f586f234fc49394db4ba5c8c5994a7", size = 293452, upload-time = "2026-03-06T21:53:07.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/3e/01795fc20f1b5f8b1d1d22eeb425c9c3396046f1761c4f6b4cc7d8dcab90/google_cloud_logging-3.14.0-py3-none-any.whl", hash = "sha256:4767ebdb3b46a3052d5185a7d5cf02829d33ea12a0aab1d57221110d581b9e1a", size = 232961, upload-time = "2026-03-06T21:52:48.393Z" }, -] - -[[package]] -name = "google-cloud-monitoring" -version = "2.29.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/06/9fc0a34bed4221a68eef3e0373ae054de367dc42c0b689d5d917587ef61b/google_cloud_monitoring-2.29.1.tar.gz", hash = "sha256:86cac55cdd2608561819d19544fb3c129bbb7dcecc445d8de426e34cd6fa8e49", size = 404383, upload-time = "2026-02-05T18:59:13.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/97/7c27aa95eccf8b62b066295a7c4ad04284364b696d3e7d9d47152b255a24/google_cloud_monitoring-2.29.1-py3-none-any.whl", hash = "sha256:944a57031f20da38617d184d5658c1f938e019e8061f27fd944584831a1b9d5a", size = 387922, upload-time = "2026-02-05T18:58:54.964Z" }, -] - -[[package]] -name = "google-cloud-pubsub" -version = "2.36.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio", marker = "python_full_version < '3.14'" }, - { name = "grpcio-status" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/f5cece431daaa2024129569ed35e6eb90a72bb51f0c96e5c7f5cab6d34d7/google_cloud_pubsub-2.36.0.tar.gz", hash = "sha256:96e057e5f83433ce428852095d652c2f7fc193f0f77db1f27cc39186fe69c1f4", size = 401324, upload-time = "2026-03-12T19:31:02.099Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/fd/d0a8f0f93a4d115282ecdd8ef0267e4611bde6ca29c9dba803f3ebae7115/google_cloud_pubsub-2.36.0-py3-none-any.whl", hash = "sha256:d6726ccf9373924e0746338dadf8244b9aa1a97a24130b59a2106c926ea37598", size = 323364, upload-time = "2026-03-12T19:30:48.077Z" }, -] - -[[package]] -name = "google-cloud-resource-manager" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/7f/db00b2820475793a52958dc55fe9ec2eb8e863546e05fcece9b921f86ebe/google_cloud_resource_manager-1.16.0.tar.gz", hash = "sha256:cc938f87cc36c2672f062b1e541650629e0d954c405a4dac35ceedee70c267c3", size = 459840, upload-time = "2026-01-15T13:04:07.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/ff/4b28bcc791d9d7e4ac8fea00fbd90ccb236afda56746a3b4564d2ae45df3/google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28", size = 400218, upload-time = "2026-01-15T13:02:47.378Z" }, -] - -[[package]] -name = "google-cloud-secret-manager" -version = "2.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/9c/a6c7144bc96df77376ae3fcc916fb639c40814c2e4bba2051d31dc136cd0/google_cloud_secret_manager-2.26.0.tar.gz", hash = "sha256:0d1d6f76327685a0ed78a4cf50f289e1bfbbe56026ed0affa98663b86d6d50d6", size = 277603, upload-time = "2025-12-18T00:29:31.065Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/30/a58739dd12cec0f7f761ed1efb518aed2250a407d4ed14c5a0eeee7eaaf9/google_cloud_secret_manager-2.26.0-py3-none-any.whl", hash = "sha256:940a5447a6ec9951446fd1a0f22c81a4303fde164cd747aae152c5f5c8e6723e", size = 223623, upload-time = "2025-12-18T00:29:29.311Z" }, -] - -[[package]] -name = "google-cloud-spanner" -version = "3.63.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-cloud-core" }, - { name = "google-cloud-monitoring" }, - { name = "grpc-google-iam-v1" }, - { name = "grpc-interceptor" }, - { name = "mmh3" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "sqlparse" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/ee/9ae0794d32ec271b2b2326f17d977d29801e5b960e7a0f03d721aeffe824/google_cloud_spanner-3.63.0.tar.gz", hash = "sha256:e2a4fb3bdbad4688645f455d498705d3f935b7c9011f5c94c137b77569b47a62", size = 729522, upload-time = "2026-02-13T07:35:13.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/72/e16c4fe5a7058c5526461ade670a4bec0922bc02c2690df27300e9955925/google_cloud_spanner-3.63.0-py3-none-any.whl", hash = "sha256:6ffae0ed589bbbd2d8831495e266198f3d069005cfe65c664448c9a727c88e7b", size = 518799, upload-time = "2026-02-13T07:35:11.993Z" }, -] - -[[package]] -name = "google-cloud-speech" -version = "2.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/29/f4/ba24128f860639ac7ddef3c1bd2f44b390f3bb0386dda65b3a65948beeed/google_cloud_speech-2.37.0.tar.gz", hash = "sha256:1b2debf721954f1157fb2631d19b29fbeeba5736e58b71aaf10734d6365add59", size = 402950, upload-time = "2026-02-27T14:12:59.384Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c5/7a0a0f6b64cd5b23a4d573d820b03b9569730a9d3dfe5aedb00f8e8a914f/google_cloud_speech-2.37.0-py3-none-any.whl", hash = "sha256:370abd51244ffc68062d655d3063e083fad525416e0cb31737f4804e3cd8588c", size = 343295, upload-time = "2026-02-27T14:12:39.579Z" }, -] - -[[package]] -name = "google-cloud-storage" -version = "3.9.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.11' and python_full_version < '3.13'", - "python_full_version < '3.11'", -] -dependencies = [ - { name = "google-api-core", marker = "python_full_version < '3.13'" }, - { name = "google-auth", marker = "python_full_version < '3.13'" }, - { name = "google-cloud-core", marker = "python_full_version < '3.13'" }, - { name = "google-crc32c", marker = "python_full_version < '3.13'" }, - { name = "google-resumable-media", marker = "python_full_version < '3.13'" }, - { name = "requests", marker = "python_full_version < '3.13'" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzlocal" }, + { name = "uvicorn" }, + { name = "watchdog" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/b1/4f0798e88285b50dfc60ed3a7de071def538b358db2da468c2e0deecbb40/google_cloud_storage-3.9.0.tar.gz", hash = "sha256:f2d8ca7db2f652be757e92573b2196e10fbc09649b5c016f8b422ad593c641cc", size = 17298544, upload-time = "2026-02-02T13:36:34.119Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/8b/d014c98e987ed3a95ac3740d2b5c8e8e891bfd88c3ac2253fca9547f3b1f/google_adk-2.5.0.tar.gz", hash = "sha256:55b88cac9d5072d511fd3224e5f334e57fb2b0ae567507e531e03fdfb60c82c2", size = 3608134, upload-time = "2026-07-16T20:43:06.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/0b/816a6ae3c9fd096937d2e5f9670558908811d57d59ddf69dd4b83b326fd1/google_cloud_storage-3.9.0-py3-none-any.whl", hash = "sha256:2dce75a9e8b3387078cbbdad44757d410ecdb916101f8ba308abf202b6968066", size = 321324, upload-time = "2026-02-02T13:36:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/52/fe/699d21edebd1305b6d23fd570140cf0cf921f34f66e4611d840684717c3a/google_adk-2.5.0-py3-none-any.whl", hash = "sha256:d247ca3639921a54a86feb797a88d08c1d2c9a60c3f5ff2805e49beb29a9cb8d", size = 4169976, upload-time = "2026-07-16T20:43:04.647Z" }, ] [[package]] -name = "google-cloud-storage" -version = "3.10.1" +name = "google-api-core" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", -] dependencies = [ - { name = "google-api-core", marker = "python_full_version >= '3.13'" }, - { name = "google-auth", marker = "python_full_version >= '3.13'" }, - { name = "google-cloud-core", marker = "python_full_version >= '3.13'" }, - { name = "google-crc32c", marker = "python_full_version >= '3.13'" }, - { name = "google-resumable-media", marker = "python_full_version >= '3.13'" }, - { name = "requests", marker = "python_full_version >= '3.13'" }, + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, ] [[package]] -name = "google-cloud-trace" -version = "1.18.0" +name = "google-auth" +version = "2.49.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, + { name = "cryptography" }, + { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/34/b1883f4682f1681941100df0e411cb0185013f7c349489ab1330348d7c5c/google_cloud_trace-1.18.0.tar.gz", hash = "sha256:46d42b90273da3bc4850bb0d6b9a205eb826a54561ff1b30ca33cc92174c3f37", size = 103347, upload-time = "2026-01-15T13:04:56.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/15/366fd8b028a50a9018c933270d220a4e53dca8022ce9086618b72978ab90/google_cloud_trace-1.18.0-py3-none-any.whl", hash = "sha256:52c002d8d3da802e031fee62cd49a1baf899932d4f548a150f685af6815b5554", size = 107488, upload-time = "2026-01-15T12:17:21.519Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, ] -[[package]] -name = "google-crc32c" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, - { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, - { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, - { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, - { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, - { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +[package.optional-dependencies] +pyopenssl = [ + { name = "pyopenssl" }, +] +requests = [ + { name = "requests" }, ] [[package]] name = "google-genai" -version = "1.75.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1751,21 +1408,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, -] - -[[package]] -name = "google-resumable-media" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-crc32c" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/9ea84cbeb8f09694564d3b0ee9dd59003551b308d47b61f251415df93982/google_genai-2.12.1.tar.gz", hash = "sha256:78c25217885d63dc430ca7c4526853512b164a25a93a8a0d0af5b85971aa1db0", size = 636710, upload-time = "2026-07-16T16:15:02.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/1369fb413fc2ba7f78acace5590b6e9990c52ab5d1d166aafaa1ae2c28c8/google_genai-2.12.1-py3-none-any.whl", hash = "sha256:686d5ec39bda345151d3ed1bac3915f01f49138b1ea519af2eb98f11cc55ebc4", size = 1023403, upload-time = "2026-07-16T16:14:59.79Z" }, ] [[package]] @@ -1780,11 +1425,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, ] -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, -] - [[package]] name = "graphviz" version = "0.21" @@ -1854,32 +1494,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos", extra = ["grpc"] }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, -] - -[[package]] -name = "grpc-interceptor" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/28/57449d5567adf4c1d3e216aaca545913fbc21a915f2da6790d6734aac76e/grpc-interceptor-0.15.4.tar.gz", hash = "sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926", size = 19322, upload-time = "2023-11-16T02:05:42.459Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/ac/8d53f230a7443401ce81791ec50a3b0e54924bf615ad287654fa4a2f5cdc/grpc_interceptor-0.15.4-py3-none-any.whl", hash = "sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d", size = 20848, upload-time = "2023-11-16T02:05:40.913Z" }, -] - [[package]] name = "grpcio" version = "1.78.0" @@ -1941,20 +1555,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, ] -[[package]] -name = "grpcio-status" -version = "1.78.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -2031,18 +1631,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httplib2" -version = "0.31.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -2301,6 +1889,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/bb/c019ac05a6923c5776fa134c65e5b19d216ef17227618d93b1f608bc2806/json_repair-0.58.6-py3-none-any.whl", hash = "sha256:e438a1e4ea03179dfe9a05dfd738e678e888f1ea5b4a40398f8f220925df1c5c", size = 43482, upload-time = "2026-03-16T13:43:33.569Z" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9", size = 28854, upload-time = "2023-06-11T09:45:49.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf", size = 39450, upload-time = "2023-06-11T09:45:47.136Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -2424,16 +2021,18 @@ wheels = [ [[package]] name = "ksadk" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ - { name = "a2a-sdk" }, + { name = "a2a-sdk", extra = ["fastapi", "postgresql"] }, + { name = "a2ui-core" }, { name = "asyncpg" }, { name = "beautifulsoup4" }, { name = "click" }, { name = "cryptography" }, { name = "fastapi" }, { name = "greenlet" }, + { name = "httpcore" }, { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, @@ -2467,17 +2066,25 @@ dependencies = [ [package.optional-dependencies] a2a = [ - { name = "a2a-sdk", extra = ["http-server"] }, + { name = "a2a-sdk", extra = ["fastapi", "postgresql"] }, ] adk = [ { name = "google-adk" }, { name = "json-repair" }, { name = "litellm", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] +agui = [ + { name = "ag-ui-langgraph", extra = ["fastapi"] }, + { name = "ag-ui-protocol" }, + { name = "copilotkit" }, +] all = [ - { name = "a2a-sdk", extra = ["http-server"] }, + { name = "a2a-sdk", extra = ["fastapi", "postgresql"] }, + { name = "ag-ui-langgraph", extra = ["fastapi"] }, + { name = "ag-ui-protocol" }, { name = "black" }, { name = "build" }, + { name = "copilotkit" }, { name = "deepagents", marker = "python_full_version >= '3.11'" }, { name = "e2b" }, { name = "fastmcp" }, @@ -2486,7 +2093,9 @@ all = [ { name = "langchain" }, { name = "langchain-core" }, { name = "langchain-openai" }, + { name = "langfuse" }, { name = "langgraph" }, + { name = "langgraph-checkpoint-sqlite" }, { name = "litellm", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, { name = "mypy" }, { name = "openinference-instrumentation-langchain" }, @@ -2495,6 +2104,14 @@ all = [ { name = "pytest-asyncio" }, { name = "ruff" }, { name = "twine" }, + { name = "types-protobuf" }, + { name = "types-python-dateutil" }, + { name = "types-pyyaml" }, + { name = "types-qrcode" }, + { name = "types-requests" }, +] +codex = [ + { name = "openai-codex" }, ] deepagents = [ { name = "deepagents", marker = "python_full_version >= '3.11'" }, @@ -2511,6 +2128,11 @@ dev = [ { name = "pytest-asyncio" }, { name = "ruff" }, { name = "twine" }, + { name = "types-protobuf" }, + { name = "types-python-dateutil" }, + { name = "types-pyyaml" }, + { name = "types-qrcode" }, + { name = "types-requests" }, ] langchain = [ { name = "langchain" }, @@ -2521,57 +2143,67 @@ langgraph = [ { name = "langchain" }, { name = "langchain-openai" }, { name = "langgraph" }, + { name = "langgraph-checkpoint-sqlite" }, { name = "protobuf" }, ] skills = [ { name = "e2b" }, ] tracing = [ + { name = "langfuse" }, { name = "openinference-instrumentation-langchain" }, ] [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.22" }, - { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a'", specifier = ">=0.3.22" }, + { name = "a2a-sdk", extras = ["fastapi", "postgresql"], specifier = "==1.1.0" }, + { name = "a2a-sdk", extras = ["fastapi", "postgresql"], marker = "extra == 'a2a'", specifier = "==1.1.0" }, + { name = "a2ui-core", specifier = "==0.1.1" }, + { name = "ag-ui-langgraph", extras = ["fastapi"], marker = "extra == 'agui'", specifier = "==0.0.42" }, + { name = "ag-ui-protocol", marker = "extra == 'agui'", specifier = "==0.1.19" }, { name = "asyncpg", specifier = ">=0.30.0,<1.0.0" }, { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=22.0.0" }, { name = "build", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "click", specifier = ">=8.0.0" }, + { name = "copilotkit", marker = "extra == 'agui'", specifier = "==0.1.94" }, { name = "cryptography", specifier = ">=44.0.0" }, { name = "deepagents", marker = "python_full_version >= '3.11' and extra == 'deepagents'", specifier = ">=0.6.2,<1.0.0" }, - { name = "e2b", marker = "extra == 'skills'", specifier = ">=2.0.0" }, + { name = "e2b", marker = "extra == 'skills'", specifier = ">=2.15.3,<2.25.0" }, { name = "fastapi", specifier = ">=0.100.0,<1.0.0" }, { name = "fastmcp", marker = "extra == 'dev'", specifier = ">=2.0.0" }, - { name = "google-adk", marker = "extra == 'adk'", specifier = ">=1.34.0,<2.0.0" }, + { name = "google-adk", marker = "extra == 'adk'", specifier = ">=1.34.0,<3.0.0" }, { name = "greenlet", specifier = ">=1.0.0" }, - { name = "httpx", specifier = ">=0.24.0" }, + { name = "httpcore", specifier = ">=1.0.9,<1.1.0" }, + { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "httpx-sse", specifier = ">=0.4.0" }, { name = "json-repair", marker = "extra == 'adk'", specifier = ">=0.25.0" }, { name = "jsonschema", specifier = ">=4.0.0,<5.0.0" }, { name = "kingsoftcloud-sdk-python", specifier = ">=1.5.8.94" }, { name = "ks3sdk", specifier = ">=1.15.0" }, - { name = "ksadk", extras = ["a2a", "adk", "langchain", "langgraph", "deepagents", "kb", "skills", "tracing", "dev"], marker = "extra == 'all'" }, - { name = "langchain", specifier = ">=1.3.0,<2.0.0" }, - { name = "langchain", marker = "extra == 'deepagents'", specifier = ">=1.3.0,<2.0.0" }, - { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.0,<2.0.0" }, - { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.3.0,<2.0.0" }, - { name = "langchain-core", specifier = ">=1.4.0,<2.0.0" }, - { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.4.0,<2.0.0" }, - { name = "langchain-openai", specifier = ">=1.2.0,<2.0.0" }, - { name = "langchain-openai", marker = "extra == 'deepagents'", specifier = ">=1.2.0,<2.0.0" }, - { name = "langchain-openai", marker = "extra == 'langchain'", specifier = ">=1.2.0,<2.0.0" }, - { name = "langchain-openai", marker = "extra == 'langgraph'", specifier = ">=1.2.0,<2.0.0" }, + { name = "ksadk", extras = ["a2a", "adk", "agui", "langchain", "langgraph", "deepagents", "kb", "skills", "tracing", "dev"], marker = "extra == 'all'" }, + { name = "langchain", specifier = ">=1.3.14,<2.0.0" }, + { name = "langchain", marker = "extra == 'deepagents'", specifier = ">=1.3.14,<2.0.0" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.14,<2.0.0" }, + { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.3.14,<2.0.0" }, + { name = "langchain-core", specifier = ">=1.5.0,<2.0.0" }, + { name = "langchain-core", marker = "extra == 'langchain'", specifier = ">=1.5.0,<2.0.0" }, + { name = "langchain-openai", specifier = ">=1.4.0,<2.0.0" }, + { name = "langchain-openai", marker = "extra == 'deepagents'", specifier = ">=1.4.0,<2.0.0" }, + { name = "langchain-openai", marker = "extra == 'langchain'", specifier = ">=1.4.0,<2.0.0" }, + { name = "langchain-openai", marker = "extra == 'langgraph'", specifier = ">=1.4.0,<2.0.0" }, + { name = "langfuse", marker = "extra == 'tracing'", specifier = ">=3.0.0" }, { name = "langgraph", specifier = ">=1.2.0,<1.3.0" }, { name = "langgraph", marker = "extra == 'deepagents'", specifier = ">=1.2.0,<1.3.0" }, { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.2.0,<1.3.0" }, + { name = "langgraph-checkpoint-sqlite", marker = "extra == 'langgraph'", specifier = ">=2.0.0" }, { name = "litellm", marker = "(python_full_version < '3.13' and extra == 'adk') or (sys_platform != 'win32' and extra == 'adk')", specifier = ">=1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "openai-codex", marker = "extra == 'codex'", specifier = "==0.144.4" }, { name = "openinference-instrumentation-langchain", marker = "extra == 'tracing'", specifier = ">=0.1.0" }, - { name = "opentelemetry-api", specifier = "==1.37.0" }, - { name = "opentelemetry-exporter-otlp", specifier = "==1.37.0" }, - { name = "opentelemetry-sdk", specifier = "==1.37.0" }, + { name = "opentelemetry-api", specifier = ">=1.39.0,<=1.41.1" }, + { name = "opentelemetry-exporter-otlp", specifier = ">=1.39.0,<=1.41.1" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<=1.41.1" }, { name = "packaging", specifier = ">=23.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "protobuf", marker = "extra == 'langgraph'", specifier = ">=6.32.1" }, @@ -2592,23 +2224,28 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "types-protobuf", marker = "extra == 'dev'", specifier = ">=6.32.0" }, + { name = "types-python-dateutil", marker = "extra == 'dev'", specifier = ">=2.9.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "types-qrcode", marker = "extra == 'dev'", specifier = ">=8.2.0" }, + { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.32.0" }, { name = "uvicorn", specifier = ">=0.23.0" }, { name = "websockets", specifier = ">=12.0,<16.0" }, ] -provides-extras = ["adk", "langchain", "langgraph", "deepagents", "a2a", "tracing", "kb", "skills", "dev", "all"] +provides-extras = ["adk", "langchain", "langgraph", "deepagents", "a2a", "tracing", "kb", "skills", "codex", "agui", "dev", "all"] [[package]] name = "langchain" -version = "1.3.1" +version = "1.3.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/11/e5/6350e77a9e2764eaafcb2d581cbf0b800f53c6bc98fdf5ebc85f3a931ded/langchain-1.3.1.tar.gz", hash = "sha256:bc283c220233230f48b8e50ab1fbf1b688bcb206d933fa448d40a9b143177f62", size = 581329, upload-time = "2026-05-15T18:14:55.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/68/a6dbad9c22df4087a0f9e79ddd46226c442b30128bfeee538d5889492a73/langchain-1.3.14.tar.gz", hash = "sha256:1b6696c72ba3bbbce54d745e0180742c9f6ece8bbc59ed5a46c3e20b9a435929", size = 645181, upload-time = "2026-07-16T13:28:18.29Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/11/3d7ed10b535413a07ed5e15682abcb77f3c4204ac49586977a495f9b24e6/langchain-1.3.1-py3-none-any.whl", hash = "sha256:154e9c30c90b391eba4315296f6bf6b6fac6b058ddea4cc771a10470968fe36f", size = 114345, upload-time = "2026-05-15T18:14:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ec/0f942e78a621f8e3162ff1ed24284f469aaf51fb4607ee5831c626f2b2bc/langchain-1.3.14-py3-none-any.whl", hash = "sha256:4d10dbe91005952cddd56d0dc77aa108964da6bae90ab20063653957e901f782", size = 139560, upload-time = "2026-07-16T13:28:16.498Z" }, ] [[package]] @@ -2627,7 +2264,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -2641,14 +2278,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/05/986c4bb148285791eb59994e0b28947bed96cac7f24467079e4274952a37/langchain_core-1.5.0.tar.gz", hash = "sha256:e1fa09d55b354192c8f60dade06a55bd6add2318c822a684555b8d4a30a16143", size = 967401, upload-time = "2026-07-21T03:37:26.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65", size = 558510, upload-time = "2026-07-21T03:37:24.423Z" }, ] [[package]] name = "langchain-google-genai" -version = "4.2.2" +version = "4.2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype", marker = "python_full_version >= '3.11'" }, @@ -2656,40 +2293,59 @@ dependencies = [ { name = "langchain-core", marker = "python_full_version >= '3.11'" }, { name = "pydantic", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/78/dfe068937338727b0dee637d971d59fe2fa275f9d0f0edee3fa80e811846/langchain_google_genai-4.2.2.tar.gz", hash = "sha256:5fc774bf41d1dc1c1a5ba8d7b9f2017dfa77e30653c9b44d2dfbaf0e877e7388", size = 267457, upload-time = "2026-04-15T15:08:32.18Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/a1acdba3a86f861d379cb654f234d334c04a4c93178c8c7b0182ddeb9966/langchain_google_genai-4.2.5.tar.gz", hash = "sha256:2abab4be22699a9cc29948b2bf012946f51a0bbf10ab3a4a9a129047234829f8", size = 271850, upload-time = "2026-06-10T01:48:57.06Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5c/adf81d68ab89b4cf505e690f8c1956d11b5969c831c951c7b4b1b1818080/langchain_google_genai-4.2.2-py3-none-any.whl", hash = "sha256:c8d09aac0304d26f1c2483e41a350f15587af1fbe034c39a304e1e17a3b743f3", size = 67605, upload-time = "2026-04-15T15:08:31.346Z" }, + { url = "https://files.pythonhosted.org/packages/6a/82/3d4d3dc181ea1756f323dad4d5936239c2f404ea0acb5102316224280634/langchain_google_genai-4.2.5-py3-none-any.whl", hash = "sha256:289699ddb8e1076a76144f83e25e0086e4ce629b196fc103251f2a629e0756e5", size = 69404, upload-time = "2026-06-10T01:48:56.09Z" }, ] [[package]] name = "langchain-openai" -version = "1.2.1" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "openai" }, { name = "tiktoken" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/0e/d8e16c28aa67106d285e63b8ffc04c5af68341e345ce24a0751dbf2e167e/langchain_openai-1.2.1.tar.gz", hash = "sha256:ee4480b787706361b7125fad46930589a624df87aa158c6986ef1fad10d10675", size = 1146092, upload-time = "2026-04-24T19:46:43.328Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/fc/d146705e0cf6cf8865d4e873e0551452f94d6520f43fe703594ccdf95763/langchain_openai-1.4.0.tar.gz", hash = "sha256:a3acf6be0937f3970fc9e7f0aae22929c6f117e49128bd62f4d45a64b2587d8b", size = 3261776, upload-time = "2026-07-21T16:09:22.723Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/55/2865b18ee3a3dd11160b8c4b2cf37e75bf2a4a8d1d38868ffffc7b7cc180/langchain_openai-1.2.1-py3-none-any.whl", hash = "sha256:a80732185030d4f453dda6c25feef46f645f665423fdffe38ae3edf1ac3c6c4d", size = 98626, upload-time = "2026-04-24T19:46:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/f786dfcb6711cb06449041e72b67f7e28fc77a53e2aa16866c4f0002625a/langchain_openai-1.4.0-py3-none-any.whl", hash = "sha256:7a777731fe32a913085ec85bacd5650c3f8422048b65346b63a13b36b1b4a12f", size = 121901, upload-time = "2026-07-21T16:09:21.61Z" }, ] [[package]] name = "langchain-protocol" -version = "0.0.15" +version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langfuse" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/61/bb7fa00f5334a670c9aae1f053bc725654a0ca5435fa17d0cb103df8c978/langfuse-4.14.1.tar.gz", hash = "sha256:576641820ae79aeca71453c8eff3c8c35d53f7e41729c918f59eb81f7499faf9", size = 381977, upload-time = "2026-07-20T08:44:04.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e4/6c305af16f1ce344271dd829383b7af586530cf60b55af1aaf274ca37ba9/langfuse-4.14.1-py3-none-any.whl", hash = "sha256:07d19f16338b8e21f8e5996b7e6c3ed150ee582fbaa6275ac9eeea297093f4be", size = 669448, upload-time = "2026-07-20T08:44:05.682Z" }, ] [[package]] name = "langgraph" -version = "1.2.0" +version = "1.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -2699,9 +2355,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/61/d5d25e783035aa307d289b37e082258a6061c0fb4caa4a284f3bf1e87169/langgraph-1.2.0.tar.gz", hash = "sha256:4a9baaf62afc5d5f63144a50095140a34b9aa9b7cea695d25326d564775348e7", size = 690248, upload-time = "2026-05-12T03:46:39.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/e8/e3304ac0015c2bdb04ad9785e4ed65c788855ce7857ce6104dd2f5d322db/langgraph-1.2.0-py3-none-any.whl", hash = "sha256:03fd5895a8d4b70db1ff63ebc3bacead29dd20cd794a8b1a483e7ec9018f7a65", size = 234262, upload-time = "2026-05-12T03:46:37.971Z" }, + { url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" }, ] [[package]] @@ -2717,6 +2373,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/74/d3be2b41955e20ccd624dba5f6fe9d38dcee385ba470a6e13ed86732fc86/langgraph_checkpoint-4.1.0-py3-none-any.whl", hash = "sha256:8bc2a0466a20c38b865ce6671b42093fd5c041133f32351cae4222e0eeaf7fb5", size = 56047, upload-time = "2026-05-12T03:33:48.548Z" }, ] +[[package]] +name = "langgraph-checkpoint-sqlite" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "langgraph-checkpoint" }, + { name = "sqlite-vec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ea/83917c2369acf8a10a894d4247655fd063c07924ba5bc4e83c85d2eaeded/langgraph_checkpoint_sqlite-3.1.0.tar.gz", hash = "sha256:f926916ebc1b985d802cc9c820026036e84db9d910d62c97b57e4ba64f67d5ae", size = 147902, upload-time = "2026-05-12T03:34:52.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/07/b342811a16327900af2747c752ea19676172fcddf9b592cc384031076623/langgraph_checkpoint_sqlite-3.1.0-py3-none-any.whl", hash = "sha256:cc9b40df0076feae8a9ad42ae713621b148b00ac23adc09dc1dc66090a46e5ad", size = 38587, upload-time = "2026-05-12T03:34:51.231Z" }, +] + [[package]] name = "langgraph-prebuilt" version = "1.1.0" @@ -2732,15 +2402,18 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.3.11" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, { name = "orjson" }, + { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/cd/a019f1b1e97c519f2425593f9bccd3ac463a18fb5d2111cff59ce1ef62fe/langgraph_sdk-0.3.11.tar.gz", hash = "sha256:3640134835d89d2c7c8bb7de73bd10673d4b282db3ff0e2fdaf1cee9e50cb1eb", size = 190387, upload-time = "2026-03-11T00:46:45.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/c8/b8d15d4b9a320a3f57a851030a371066b91dbd1420f097d3d0338da9adc9/langgraph_sdk-0.3.11-py3-none-any.whl", hash = "sha256:18905fd6248ade98b0995d859a98672d57c811fbfffc0d63d1c107a512351b26", size = 94887, upload-time = "2026-03-11T00:46:43.855Z" }, + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, ] [[package]] @@ -2962,18 +2635,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/0271227eab939921a12ebba5d17aa4cd18346aa534ca7f5da09cd0b63dd4/lupa-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:86f6f668966965b15247dc32d064cfe7be67b71e584ccfacbe2f637575296878", size = 1847445, upload-time = "2026-04-15T20:08:27.031Z" }, ] -[[package]] -name = "mako" -version = "1.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, -] - [[package]] name = "markdown-it-py" version = "4.0.0" @@ -3105,120 +2766,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mmh3" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/bb/88ee54afa5644b0f35ab5b435f208394feb963e5bb47c4e404deb625ffa4/mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f", size = 56080, upload-time = "2026-03-05T15:53:40.452Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/5404c2fd6ac84819e8ff1b7e34437b37cf55a2b11318894909e7bb88de3f/mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb", size = 40462, upload-time = "2026-03-05T15:53:41.751Z" }, - { url = "https://files.pythonhosted.org/packages/de/0b/52bffad0b52ae4ea53e222b594bd38c08ecac1fc410323220a7202e43da5/mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c", size = 40077, upload-time = "2026-03-05T15:53:42.753Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9e/326c93d425b9fa4cbcdc71bc32aaba520db37577d632a24d25d927594eca/mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045", size = 95302, upload-time = "2026-03-05T15:53:43.867Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b1/e20d5f0d19c4c0f3df213fa7dcfa0942c4fb127d38e11f398ae8ddf6cccc/mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f", size = 101174, upload-time = "2026-03-05T15:53:45.194Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4a/1a9bb3e33c18b1e1cee2c249a3053c4d4d9c93ecb30738f39a62249a7e86/mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386", size = 103979, upload-time = "2026-03-05T15:53:46.334Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/dab9ee7545429e7acdd38d23d0104471d31de09a0c695f1b751e0ff34532/mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a", size = 110898, upload-time = "2026-03-05T15:53:47.443Z" }, - { url = "https://files.pythonhosted.org/packages/72/08/408f11af7fe9e76b883142bb06536007cc7f237be2a5e9ad4e837716e627/mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0", size = 118308, upload-time = "2026-03-05T15:53:49.1Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/0551be7fe0000736d9ad12ffa1f130d7a0c17b49193d6dc41c82bd9404c6/mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb", size = 101671, upload-time = "2026-03-05T15:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/44/17/6e4f80c4e6ad590139fa2017c3aeca54e7cc9ef68e08aa142a0c90f40a97/mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890", size = 96682, upload-time = "2026-03-05T15:53:51.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a7/b82fccd38c1fa815de72e94ebe9874562964a10e21e6c1bc3b01d3f15a0e/mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a", size = 110287, upload-time = "2026-03-05T15:53:52.68Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a1/2644069031c8cec0be46f0346f568a53f42fddd843f03cc890306699c1e2/mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5", size = 111899, upload-time = "2026-03-05T15:53:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/6614f3eb8fb33f931fa7616c6d477247e48ec6c5082b02eeeee998cffa94/mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57", size = 100078, upload-time = "2026-03-05T15:53:55.234Z" }, - { url = "https://files.pythonhosted.org/packages/27/9a/dd4d5a5fb893e64f71b42b69ecae97dd78db35075412488b24036bc5599c/mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518", size = 40756, upload-time = "2026-03-05T15:53:56.319Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/0b25889450f8aeffcec840aa73251e853f059c1b72ed1d1c027b956f95f5/mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f", size = 41519, upload-time = "2026-03-05T15:53:57.41Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/8fd42e3c526d0bcb1db7f569c0de6729e180860a0495e387a53af33c2043/mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0", size = 39285, upload-time = "2026-03-05T15:53:58.697Z" }, - { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, - { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, - { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, - { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, - { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, - { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, - { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, - { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, - { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, - { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, - { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, - { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, - { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, - { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, - { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, - { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, - { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, - { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, - { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, - { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, - { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, - { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, - { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, - { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, - { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, - { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, - { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, - { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, - { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, - { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, - { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, - { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, -] - [[package]] name = "more-itertools" version = "11.1.0" @@ -3699,7 +3246,7 @@ wheels = [ [[package]] name = "openai" -version = "2.29.0" +version = "2.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3711,9 +3258,37 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/61/9aeef14de759306e85175126d3d6d56ee4f5072a9512c6c171d58d02a62d/openai-2.47.0.tar.gz", hash = "sha256:4e205548acd4304f235b86202269912e55bc88270b15d2a051fa2b53b90343a6", size = 1089906, upload-time = "2026-07-22T17:47:29.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/69/26b032059273ad798d18fbcdbe369e871181841fd8bcb5caee32b7510039/openai-2.47.0-py3-none-any.whl", hash = "sha256:b3a1a7ad974092427ccb46d89f8852bdb67866680bcabeecc3ff5a3fdd71b15b", size = 1639987, upload-time = "2026-07-22T17:47:27.873Z" }, +] + +[[package]] +name = "openai-codex" +version = "0.144.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openai-codex-cli-bin" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/7d/4b999b0ddd05c22d83cc2e879c95e1e92e3b7808b58ac561c4ea91fca1c4/openai_codex-0.144.4.tar.gz", hash = "sha256:91c63a7cb213441569f130e593386b34657ab9e726ae88af255f0ecb8de08ea5", size = 68324, upload-time = "2026-07-17T23:42:17.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/35/5d2a13e38d91278019f18757ac10426d2649b7ec6f031818c792f64559e9/openai_codex-0.144.4-py3-none-any.whl", hash = "sha256:de1513a6e94b9a8d7728a3b74298bc1469428ade10ba0ef2d5db47dd1cb606f5", size = 76244, upload-time = "2026-07-17T23:42:15.658Z" }, +] + +[[package]] +name = "openai-codex-cli-bin" +version = "0.144.4" +source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/7e457c007a32aa7333a78438a9f532504c3b77a1ea57e7808855712c2c0f/openai_codex_cli_bin-0.144.4-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:4d587d152d5f0aa25f21ded4d08aac5150da48639b29fc3e57b2a8b413d06903", size = 126851183, upload-time = "2026-07-15T00:14:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/64c180514a2cc3e2500e486813f5a8d7f7e349342e9bebd78d99ddd9791a/openai_codex_cli_bin-0.144.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:05db505a9c7f020f58b70837a94e00d32a50086986c267bcc44ea97b573d4a05", size = 116474758, upload-time = "2026-07-15T00:14:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/85/34/921ab692c7ed140941a91e39b84c6deafddb45072793f3a5f6dcbf86f59a/openai_codex_cli_bin-0.144.4-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:cd4bb31b8a477a3adba22139c8c493693fa5292ba21e86eef08ace3e96c41294", size = 119437596, upload-time = "2026-07-15T00:14:17.418Z" }, + { url = "https://files.pythonhosted.org/packages/25/62/39e630cf8b7b2e2444a5a6669235a6cd88004869a25bb7f3f629ed35638c/openai_codex_cli_bin-0.144.4-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:4106229c38f37245c3eea8d904426afd45b8bf19831765715647f5402f757c06", size = 128817757, upload-time = "2026-07-15T00:14:30.539Z" }, + { url = "https://files.pythonhosted.org/packages/23/24/318f91a95baaff845307e529d138d42a7202e6bd0e626556058eab3dead5/openai_codex_cli_bin-0.144.4-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:d2d3fada11731938e3d3e3660819d5324b7f92e825a0ed5aede63100b36ffc9a", size = 119437594, upload-time = "2026-07-15T00:14:39.327Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a0/65e6fd3a6fba52639937801d9ce517b0c48026458723bb9f08eb07a1dd35/openai_codex_cli_bin-0.144.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:70fb62ed7755e332dd8b00ed48e22ee16df10f4a977335039e237cd330490df3", size = 128817755, upload-time = "2026-07-15T00:14:47.849Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8a/3ab5fc97352e8f780d472c8dd6d7504d6a670cd7dede106d461ac1171999/openai_codex_cli_bin-0.144.4-py3-none-win_amd64.whl", hash = "sha256:56e142974467332f1f669b89f2636e06b3ada09413512abb2f24c33b4a4f59fc", size = 140595092, upload-time = "2026-07-15T00:14:58.484Z" }, + { url = "https://files.pythonhosted.org/packages/70/1a/3aa52ab8f89e0f596b6eea835e3f3494697da51917d3231f5f48f92e2bcb/openai_codex_cli_bin-0.144.4-py3-none-win_arm64.whl", hash = "sha256:2ad01058db7181323ae7c6217e560702e38c4f107595b99ab1d0abc9f184890a", size = 130665105, upload-time = "2026-07-15T00:15:08.861Z" }, ] [[package]] @@ -3790,90 +3365,45 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/04/05040d7ce33a907a2a02257e601992f0cdf11c73b33f13c4492bf6c3d6d5/opentelemetry_api-1.37.0.tar.gz", hash = "sha256:540735b120355bd5112738ea53621f8d5edb35ebcd6fe21ada3ab1c61d1cd9a7", size = 64923, upload-time = "2025-09-11T10:29:01.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/48/28ed9e55dcf2f453128df738210a980e09f4e468a456fa3c763dbc8be70a/opentelemetry_api-1.37.0-py3-none-any.whl", hash = "sha256:accf2024d3e89faec14302213bc39550ec0f4095d1cf5ca688e1bfb1c8612f47", size = 65732, upload-time = "2025-09-11T10:28:41.826Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-logging" -version = "1.11.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-logging" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/2d/6aa7063b009768d8f9415b36a29ae9b3eb1e2c5eff70f58ca15e104c245f/opentelemetry_exporter_gcp_logging-1.11.0a0.tar.gz", hash = "sha256:58496f11b930c84570060ffbd4343cd0b597ea13c7bc5c879df01163dd552f14", size = 22400, upload-time = "2025-11-04T19:32:13.812Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/b7/2d3df53fa39bfd52f88c78a60367d45a7b1adbf8a756cce62d6ac149d49a/opentelemetry_exporter_gcp_logging-1.11.0a0-py3-none-any.whl", hash = "sha256:f8357c552947cb9c0101c4575a7702b8d3268e28bdeefdd1405cf838e128c6ef", size = 14168, upload-time = "2025-11-04T19:32:07.073Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-monitoring" -version = "1.11.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-monitoring" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/48/d1c7d2380bb1754d1eb6a011a2e0de08c6868cb6c0f34bcda0444fa0d614/opentelemetry_exporter_gcp_monitoring-1.11.0a0.tar.gz", hash = "sha256:386276eddbbd978a6f30fafd3397975beeb02a1302bdad554185242a8e2c343c", size = 20828, upload-time = "2025-11-04T19:32:14.522Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/8c/03a6e73e270a9c890dbd6cc1c47c83d86b8a8a974a9168d92e043c6277cc/opentelemetry_exporter_gcp_monitoring-1.11.0a0-py3-none-any.whl", hash = "sha256:b6740cba61b2f9555274829fe87a58447b64d0378f1067a4faebb4f5b364ca22", size = 13611, upload-time = "2025-11-04T19:32:08.212Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-trace" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-trace" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/10/9c/4c3b26e5494f8b53c7873732a2317df905abe2b8ab33e9edfcbd5a8ff79b/opentelemetry_exporter_gcp_trace-1.11.0.tar.gz", hash = "sha256:c947ab4ab53e16517ade23d6fe71fe88cf7ca3f57a42c9f0e4162d2b929fecb6", size = 18770, upload-time = "2025-11-04T19:32:15.109Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/4a/876703e8c5845198d95cd4006c8d1b2e3b129a9e288558e33133360f8d5d/opentelemetry_exporter_gcp_trace-1.11.0-py3-none-any.whl", hash = "sha256:b3dcb314e1a9985e9185cb7720b693eb393886fde98ae4c095ffc0893de6cefa", size = 14016, upload-time = "2025-11-04T19:32:09.009Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] [[package]] name = "opentelemetry-exporter-otlp" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/df/47fde1de15a3d5ad410e98710fac60cd3d509df5dc7ec1359b71d6bf7e70/opentelemetry_exporter_otlp-1.37.0.tar.gz", hash = "sha256:f85b1929dd0d750751cc9159376fb05aa88bb7a08b6cdbf84edb0054d93e9f26", size = 6145, upload-time = "2025-09-11T10:29:03.075Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/84/d55baf8e1a222f40282956083e67de9fa92d5fa451108df4839505fa2a24/opentelemetry_exporter_otlp-1.41.1.tar.gz", hash = "sha256:299a2f0541ca175df186f5ac58fd5db177ba1e9b72b0826049062f750d55b47f", size = 6152, upload-time = "2026-04-24T13:15:40.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/23/7e35e41111e3834d918e414eca41555d585e8860c9149507298bb3b9b061/opentelemetry_exporter_otlp-1.37.0-py3-none-any.whl", hash = "sha256:bd44592c6bc7fc3e5c0a9b60f2ee813c84c2800c449e59504ab93f356cc450fc", size = 7019, upload-time = "2025-09-11T10:28:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/ea4aa7dfc458fd537bd9519ea0e7226eef2a6212dfe952694984167daaba/opentelemetry_exporter_otlp-1.41.1-py3-none-any.whl", hash = "sha256:db276c5a80c02b063994e80950d00ca1bfddcf6520f608335b7dc2db0c0eb9c6", size = 7025, upload-time = "2026-04-24T13:15:17.839Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/6c/10018cbcc1e6fff23aac67d7fd977c3d692dbe5f9ef9bb4db5c1268726cc/opentelemetry_exporter_otlp_proto_common-1.37.0.tar.gz", hash = "sha256:c87a1bdd9f41fdc408d9cc9367bb53f8d2602829659f2b90be9f9d79d0bfe62c", size = 20430, upload-time = "2025-09-11T10:29:03.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/13/b4ef09837409a777f3c0af2a5b4ba9b7af34872bc43609dda0c209e4060d/opentelemetry_exporter_otlp_proto_common-1.37.0-py3-none-any.whl", hash = "sha256:53038428449c559b0c564b8d718df3314da387109c4d36bd1b94c9a641b0292e", size = 18359, upload-time = "2025-09-11T10:28:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -3884,14 +3414,14 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/11/4ad0979d0bb13ae5a845214e97c8d42da43980034c30d6f72d8e0ebe580e/opentelemetry_exporter_otlp_proto_grpc-1.37.0.tar.gz", hash = "sha256:f55bcb9fc848ce05ad3dd954058bc7b126624d22c4d9e958da24d8537763bec5", size = 24465, upload-time = "2025-09-11T10:29:04.172Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/17/46630b74751031a658706bef23ac99cdc2953cd3b2d28ec90590a0766b3e/opentelemetry_exporter_otlp_proto_grpc-1.37.0-py3-none-any.whl", hash = "sha256:aee5104835bf7993b7ddaaf380b6467472abaedb1f1dbfcc54a52a7d781a3890", size = 19305, upload-time = "2025-09-11T10:28:45.776Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, @@ -3902,14 +3432,14 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/e3/6e320aeb24f951449e73867e53c55542bebbaf24faeee7623ef677d66736/opentelemetry_exporter_otlp_proto_http-1.37.0.tar.gz", hash = "sha256:e52e8600f1720d6de298419a802108a8f5afa63c96809ff83becb03f874e44ac", size = 17281, upload-time = "2025-09-11T10:29:04.844Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/e9/70d74a664d83976556cec395d6bfedd9b85ec1498b778367d5f93e373397/opentelemetry_exporter_otlp_proto_http-1.37.0-py3-none-any.whl", hash = "sha256:54c42b39945a6cc9d9a2a33decb876eabb9547e0dcb49df090122773447f1aef", size = 19576, upload-time = "2025-09-11T10:28:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.58b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -3917,63 +3447,48 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/36/7c307d9be8ce4ee7beb86d7f1d31027f2a6a89228240405a858d6e4d64f9/opentelemetry_instrumentation-0.58b0.tar.gz", hash = "sha256:df640f3ac715a3e05af145c18f527f4422c6ab6c467e40bd24d2ad75a00cb705", size = 31549, upload-time = "2025-09-11T11:42:14.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/db/5ff1cd6c5ca1d12ecf1b73be16fbb2a8af2114ee46d4b0e6d4b23f4f4db7/opentelemetry_instrumentation-0.58b0-py3-none-any.whl", hash = "sha256:50f97ac03100676c9f7fc28197f8240c7290ca1baa12da8bfbb9a1de4f34cc45", size = 33019, upload-time = "2025-09-11T11:41:00.624Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/ea/a75f36b463a36f3c5a10c0b5292c58b31dbdde74f6f905d3d0ab2313987b/opentelemetry_proto-1.37.0.tar.gz", hash = "sha256:30f5c494faf66f77faeaefa35ed4443c5edb3b0aa46dad073ed7210e1a789538", size = 46151, upload-time = "2025-09-11T10:29:11.04Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/25/f89ea66c59bd7687e218361826c969443c4fa15dfe89733f3bf1e2a9e971/opentelemetry_proto-1.37.0-py3-none-any.whl", hash = "sha256:8ed8c066ae8828bbf0c39229979bdf583a126981142378a9cbe9d6fd5701c6e2", size = 72534, upload-time = "2025-09-11T10:28:56.831Z" }, -] - -[[package]] -name = "opentelemetry-resourcedetector-gcp" -version = "1.11.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/5d/2b3240d914b87b6dd9cd5ca2ef1ccaf1d0626b897d4c06877e22c8c10fcf/opentelemetry_resourcedetector_gcp-1.11.0a0.tar.gz", hash = "sha256:915a1d6fd15daca9eedd3fc52b0f705375054f2ef140e2e7a6b4cca95a47cdb1", size = 18796, upload-time = "2025-11-04T19:32:16.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6c/1e13fe142a7ca3dc6489167203a1209d32430cca12775e1df9c9a41c54b2/opentelemetry_resourcedetector_gcp-1.11.0a0-py3-none-any.whl", hash = "sha256:5d65a2a039b1d40c6f41421dbb08d5f441368275ac6de6e76a8fccd1f6acb67e", size = 18798, upload-time = "2025-11-04T19:32:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.37.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f4/62/2e0ca80d7fe94f0b193135375da92c640d15fe81f636658d2acf373086bc/opentelemetry_sdk-1.37.0.tar.gz", hash = "sha256:cc8e089c10953ded765b5ab5669b198bbe0af1b3f89f1007d19acd32dc46dda5", size = 170404, upload-time = "2025-09-11T10:29:11.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/62/9f4ad6a54126fb00f7ed4bb5034964c6e4f00fcd5a905e115bd22707e20d/opentelemetry_sdk-1.37.0-py3-none-any.whl", hash = "sha256:8f3c3c22063e52475c5dbced7209495c2c16723d016d39287dfc215d1771257c", size = 131941, upload-time = "2025-09-11T10:28:57.83Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.58b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/1b/90701d91e6300d9f2fb352153fb1721ed99ed1f6ea14fa992c756016e63a/opentelemetry_semantic_conventions-0.58b0.tar.gz", hash = "sha256:6bd46f51264279c433755767bb44ad00f1c9e2367e1b42af563372c5a6fa0c25", size = 129867, upload-time = "2025-09-11T10:29:12.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/90/68152b7465f50285d3ce2481b3aec2f82822e3f52e5152eeeaf516bab841/opentelemetry_semantic_conventions-0.58b0-py3-none-any.whl", hash = "sha256:5564905ab1458b96684db1340232729fce3b5375a06e140e8904c78e4f815b28", size = 207954, upload-time = "2025-09-11T10:28:59.218Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] [[package]] @@ -4122,6 +3637,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "partialjson" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/b2/59669fdc3ecbc724a077c598c1c9b4068549af0cd8c3b5add9337bd4d93a/partialjson-0.0.8.tar.gz", hash = "sha256:91217e19a15049332df534477f56420065ad1729cedee7d8c7433e1d2acc7dca", size = 4142, upload-time = "2024-08-03T18:03:15.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/fb/453af21468774dbd0954853735a4fc7841544c3022ff86e5d93252d7ea72/partialjson-0.0.8-py3-none-any.whl", hash = "sha256:22c6c60944137f931a7033fa0eeee2d74b49114f3d45c25a560b07a6ebf22b76", size = 4549, upload-time = "2024-08-03T18:03:14.447Z" }, +] + [[package]] name = "pathable" version = "0.6.0" @@ -4468,63 +3992,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, ] -[[package]] -name = "pyarrow" -version = "23.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/a8/24e5dc6855f50a62936ceb004e6e9645e4219a8065f304145d7fb8a79d5d/pyarrow-23.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", size = 34307390, upload-time = "2026-02-16T10:08:08.654Z" }, - { url = "https://files.pythonhosted.org/packages/bc/8e/4be5617b4aaae0287f621ad31c6036e5f63118cfca0dc57d42121ff49b51/pyarrow-23.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", size = 35853761, upload-time = "2026-02-16T10:08:17.811Z" }, - { url = "https://files.pythonhosted.org/packages/2e/08/3e56a18819462210432ae37d10f5c8eed3828be1d6c751b6e6a2e93c286a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", size = 44493116, upload-time = "2026-02-16T10:08:25.792Z" }, - { url = "https://files.pythonhosted.org/packages/f8/82/c40b68001dbec8a3faa4c08cd8c200798ac732d2854537c5449dc859f55a/pyarrow-23.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", size = 47564532, upload-time = "2026-02-16T10:08:34.27Z" }, - { url = "https://files.pythonhosted.org/packages/20/bc/73f611989116b6f53347581b02177f9f620efdf3cd3f405d0e83cdf53a83/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", size = 48183685, upload-time = "2026-02-16T10:08:42.889Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/6c6b3ecdae2a8c3aced99956187e8302fc954cc2cca2a37cf2111dad16ce/pyarrow-23.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", size = 50605582, upload-time = "2026-02-16T10:08:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/8d/94/d359e708672878d7638a04a0448edf7c707f9e5606cee11e15aaa5c7535a/pyarrow-23.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", size = 27521148, upload-time = "2026-02-16T10:08:58.077Z" }, - { url = "https://files.pythonhosted.org/packages/b0/41/8e6b6ef7e225d4ceead8459427a52afdc23379768f54dd3566014d7618c1/pyarrow-23.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", size = 34302230, upload-time = "2026-02-16T10:09:03.859Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4a/1472c00392f521fea03ae93408bf445cc7bfa1ab81683faf9bc188e36629/pyarrow-23.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", size = 35850050, upload-time = "2026-02-16T10:09:11.877Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b2/bd1f2f05ded56af7f54d702c8364c9c43cd6abb91b0e9933f3d77b4f4132/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd", size = 44491918, upload-time = "2026-02-16T10:09:18.144Z" }, - { url = "https://files.pythonhosted.org/packages/0b/62/96459ef5b67957eac38a90f541d1c28833d1b367f014a482cb63f3b7cd2d/pyarrow-23.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", size = 47562811, upload-time = "2026-02-16T10:09:25.792Z" }, - { url = "https://files.pythonhosted.org/packages/7d/94/1170e235add1f5f45a954e26cd0e906e7e74e23392dcb560de471f7366ec/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", size = 48183766, upload-time = "2026-02-16T10:09:34.645Z" }, - { url = "https://files.pythonhosted.org/packages/0e/2d/39a42af4570377b99774cdb47f63ee6c7da7616bd55b3d5001aa18edfe4f/pyarrow-23.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", size = 50607669, upload-time = "2026-02-16T10:09:44.153Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/db94101c187f3df742133ac837e93b1f269ebdac49427f8310ee40b6a58f/pyarrow-23.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", size = 27527698, upload-time = "2026-02-16T10:09:50.263Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, - { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, - { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, - { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, - { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, - { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, - { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, - { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, - { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, - { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, - { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, - { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, - { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, - { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, - { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, - { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -4837,15 +4304,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, ] -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - [[package]] name = "pypdf" version = "6.10.1" @@ -5703,27 +5161,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] -[[package]] -name = "sqlalchemy-spanner" -version = "1.17.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alembic" }, - { name = "google-cloud-spanner" }, - { name = "sqlalchemy" }, +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/29/21698bb83e542f32e3581886671f39d94b1f7e8b190c24a8bfa994e62fd6/sqlalchemy_spanner-1.17.2.tar.gz", hash = "sha256:56ce4da7168a27442d80ffd71c29ed639b5056d7e69b1e69bb9c1e10190b67c4", size = 82745, upload-time = "2025-12-15T23:30:08.622Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/87/05be45a086116cea32cfa00fa0059d31b5345360dba7902ee640a1db793b/sqlalchemy_spanner-1.17.2-py3-none-any.whl", hash = "sha256:18713d4d78e0bf048eda0f7a5c80733e08a7b678b34349496415f37652efb12f", size = 31917, upload-time = "2025-12-15T23:30:07.356Z" }, +postgresql-asyncpg = [ + { name = "asyncpg" }, + { name = "greenlet" }, ] [[package]] -name = "sqlparse" -version = "0.5.5" +name = "sqlite-vec" +version = "0.1.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, ] [[package]] @@ -5741,15 +5197,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -5877,6 +5333,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -5978,6 +5443,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] +[[package]] +name = "types-protobuf" +version = "7.34.1.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/59/e2b13b499d15e6720150c4b1a8d91e31fcacf716b432397475b3151ff7e4/types_protobuf-7.34.1.20260518.tar.gz", hash = "sha256:28cfaded25889cb83ebfb63cfb0a43628f0b6f3785767bec17287dc6468795f2", size = 68936, upload-time = "2026-05-18T06:01:47.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/1f/ec5caf72c2e3b688ca3927e0979a04ddad19e1afc4bf1c199bd743e0f419/types_protobuf-7.34.1.20260518-py3-none-any.whl", hash = "sha256:a0a5337413347166439c0e07cbc26c6164d091401c6f01b1dfd8cdb966c4dd8f", size = 85992, upload-time = "2026-05-18T06:01:45.696Z" }, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20260716" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/9a/aefb9f80ff79641d36149352afe66ca835c5e6894deb518418f786f4b8ad/types_python_dateutil-2.9.0.20260716.tar.gz", hash = "sha256:1d55d1c3024bdb4861bb6a6622c9ec800c433d87bdc5b16fb84cdd0eed4ef2cb", size = 17516, upload-time = "2026-07-16T04:48:06.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/85/cff97326a81e7e8877803e78e3ea56c840b85bb811934b9c936f7c40f185/types_python_dateutil-2.9.0.20260716-py3-none-any.whl", hash = "sha256:1ae41d51a5c5f6bbeeb7f1f34df7086d55ff1605cf88d2ed71a7a276e1a7794e", size = 18443, upload-time = "2026-07-16T04:48:05.793Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "types-qrcode" +version = "8.2.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/08/6f4cbdbdc1238e07ea0ad42ad45a6ea89fef6d5606782b0398bdf5a63f5f/types_qrcode-8.2.0.20260518.tar.gz", hash = "sha256:5079a2bf5fe57c8c44ae16867c5b41520522b58d21bb07e886770b7413160b04", size = 15280, upload-time = "2026-05-18T06:07:13.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/6f/48acc11e4b5cf84ab8a04d065691b3d6d16a535821ba5ad2efcc3548f227/types_qrcode-8.2.0.20260518-py3-none-any.whl", hash = "sha256:7dc344a5877a92c03e2ceeda33bc91af1e09cc8b574b87729c4b66ccb46b0f55", size = 19823, upload-time = "2026-05-18T06:07:12.257Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -6029,15 +5545,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" version = "2.6.3"