diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f53778..29c0d9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ 格式参考 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html)。 +## [Unreleased] + +## [0.7.0] - 2026-07-15 + +### 亮点 + +- **Google ADK 长任务支持 invocation 级断点恢复**:KsADK 记录 ADK invocation 映射和最新工具/Agent 状态恢复点,通过共享 PostgreSQL session backend 支持 Pod 重建后继续同一个 run;控制台可按 runtime capability、checkpoint preview 和恢复风险决定是否展示恢复入口。 +- **交互终端改为内容优先的 inline TUI**:`agentengine run` / `invoke` 的聊天界面不再占用 alternate screen,保留终端原生 scrollback;流式文本、工具调用和工具结果按实际到达顺序展示,并支持 `/tools` 折叠或展开工具详情。 +- **PostgreSQL 会话故障不阻断 Agent**:配置 PostgreSQL 时,驱动缺失、连接或写入异常会进入进程内存降级态并记录结构化告警;驱动补齐或后台探活恢复后,新会话和后续事件自动恢复写入 PostgreSQL。 +- **会话事件更易排查**:新增 `ksadk_session_events_readable` PostgreSQL 视图,将原始事件拍平为消息角色、文本、工具名称、生命周期状态和时间等字段。 +- **usage 与可观测数据更准确**:LangGraph 流式 usage 会去重、聚合并优先作为当前轮权威值;Langfuse trace 保留 KsADK 已计算的 usage,避免 exporter 二次推断覆盖正确结果。 + +### 新增 + +- ADK Runner 接入 `ResumabilityConfig`、`invocation_id` 映射、递增 checkpoint、恢复审计和 runtime capability 描述;新增 `KSADK_ADK_RESUMABLE` 显式开关。 +- TUI 新增 `/tools` 命令、模型选择、输入排队、处理中耗时状态、终端背景自适应和 `/clear` 原生 scrollback 清理。 +- 新增 PostgreSQL 会话故障恢复 E2E 校验脚本,覆盖 LangGraph、LangChain、ADK、可读视图以及数据库中断后恢复写入。 + +### 变更 + +- ADK checkpoint 只在共享 database session backend 下声明可跨 Pod 恢复;in-memory、local 和 SQLite backend 保留审计信息但不点亮恢复入口。Runtime bootstrap 的 Stop/Resume/checkpoint 字段改为跟随 runner 实际能力。 +- 新建 Agent、Hermes、OpenClaw 和 MCP 部署时默认开启公网访问;更新已有资源时,未显式传入网络选项则不覆盖服务端现有配置,仍可通过 `--disable-public-access` 明确关闭。 +- `agentengine invoke --message` 默认创建新 session,只有显式传入 session 时才复用,避免多次单次调用意外共享上下文。 +- 模型上下文窗口区分 provider 返回的原始上限与扣除系统预留后的有效上限,避免 UI 展示值和运行时裁剪阈值混用。 +- reasoning delta 仍实时流式返回,但每轮聚合为一条 `reasoning` 事件持久化,减少 PostgreSQL 写放大。 +- 降级期间未写入 PostgreSQL 的旧事件不会自动补写;Pod 重启、迁移或请求切换到其他 Pod 时,历史上下文可能不完整,但当前 Agent 请求继续执行。 +- PostgreSQL 连接池操作和关闭增加超时边界,避免数据库网络异常拖住请求或 Pod 退出。 +- PostgreSQL session 创建改为数据库级幂等,多个 Pod 并发恢复或创建同一 session 时不会因唯一键竞争误触发降级。 +- 公开发布流程统一为内部 `master` 经 clean export 生成公开候选,再通过 GitHub PR、Trusted Publishing 发布主包和兼容别名包;移除旧文档部署路径并补齐导出审计边界。 + +### 修复 + +- 修复 ADK 恢复能力关闭时被空消息静默转换为新任务、并发 invocation 映射串线、checkpoint ID 重启碰撞、零事件恢复异常和重复审计的问题。 +- 修复 ADK LiteLLM 非法工具参数补丁返回错误响应类型及重复包装,并补齐 MCP 结果补丁的幂等保护。 +- 修复 inline TUI 中工具调用完成后被统一移动到回复末尾、历史重绘影响原生滚动、浅色终端输入区域不清晰等问题。 +- 修复 Remote Runner 的 chat stream 将文本 delta 放在顶层时无法解析,导致远程回复内容缺失的问题。 +- 修复 LangGraph 流式 usage chunk 重复、缺少 run id 或与最终事件并存时可能重复累计或丢失的问题。 +- 修复 Langfuse exporter 覆盖 KsADK 权威 usage,导致 trace 中 token 数据与运行时不一致的问题。 +- 修复 PostgreSQL 恢复后,降级期间创建的 session 无法继续写入 PG 的问题。 +- 修复 ADK session 在健康 PostgreSQL 下不刷新其他副本新增事件的问题。 +- 修复 DeepAgents 测试 fake model 对空内容 tool call 不产生 stream chunk 的兼容问题。 +- 修复 `langchain.agents.create_agent()` 返回 message-state 时,LangChain Runner 丢弃流式文本、思考过程和工具调用/结果并回退为同步执行的问题。 +- 修复 Windows Python 3.13 安装 ADK extra 时可能从源码构建 LiteLLM 并因缺少 MSVC linker 失败的问题;显式使用 `LiteLlm` 的用户可先安装官方二进制 wheel。 +- 修复 coding profile 未直接暴露 workspace 写文件工具,导致新建文件绕行 dispatcher;同时明确 read/edit 必须串行并让无效编辑参数优先返回准确诊断。 +- 修复本地 `agentengine web` 缺少 `ListSessionMessages` action,导致刷新或切换 session 后历史消息无法回显的问题;历史投影保留正文、思考、工具、审批和附件信息。 + ## [0.6.9] - 2026-07-07 ### 亮点 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 ea55d691..f806ed18 100644 --- a/docs-site/content/docs/framework/tutorials/adk-agent.en.mdx +++ b/docs-site/content/docs/framework/tutorials/adk-agent.en.mdx @@ -16,6 +16,10 @@ source .venv/bin/activate pip install -U "ksadk[adk]" ``` + +On Windows Python 3.13, `ksadk[adk]` does not install LiteLLM automatically. This avoids accidental source builds from a mirror or cache that require a local MSVC/Rust toolchain. If your agent explicitly uses `google.adk.models.lite_llm.LiteLlm`, install the official wheel first: `python -m pip install --only-binary=litellm litellm`. + + Create `agentengine.yaml`: ```yaml diff --git a/docs-site/content/docs/framework/tutorials/adk-agent.mdx b/docs-site/content/docs/framework/tutorials/adk-agent.mdx index 4f0434ff..01bdf907 100644 --- a/docs-site/content/docs/framework/tutorials/adk-agent.mdx +++ b/docs-site/content/docs/framework/tutorials/adk-agent.mdx @@ -15,6 +15,10 @@ source .venv/bin/activate pip install -U "ksadk[adk]" ``` + +Windows Python 3.13 不会由 `ksadk[adk]` 自动安装 LiteLLM,避免镜像或缓存误选源码包后要求本机具备 MSVC/Rust 构建链。若 Agent 显式使用 `google.adk.models.lite_llm.LiteLlm`,请先安装官方 wheel:`python -m pip install --only-binary=litellm litellm`。 + + 创建 `agentengine.yaml`: ```yaml diff --git a/docs-site/content/docs/references/runtime-sessions-files.en.mdx b/docs-site/content/docs/references/runtime-sessions-files.en.mdx index 71faa73d..d5e5aa62 100644 --- a/docs-site/content/docs/references/runtime-sessions-files.en.mdx +++ b/docs-site/content/docs/references/runtime-sessions-files.en.mdx @@ -80,6 +80,43 @@ Older local clients may use `session_id`: Use one style consistently. If both are present and disagree, the local runtime rejects the request. +When PostgreSQL is configured, it is a durable session replica rather than an +availability prerequisite for agent execution. If a connection or write fails, +the runtime continues with its in-process live session and emits a structured +error when it enters `session_backend_state=degraded`. The runtime periodically +probes PostgreSQL; after recovery, new sessions and subsequent events are written +to PostgreSQL again and `session_backend_state=recovered` is logged. Older events +missed during the outage are not backfilled automatically and are not guaranteed +to survive a pod restart or relocation. While PostgreSQL is unavailable, only +history already loaded by the current process is available. A request routed to +another pod may have reduced context, but the current agent request still runs. + +### Readable PostgreSQL View + +KsADK creates `ksadk_session_events_readable` alongside the raw PostgreSQL +tables. The raw `content_json` and `metadata_json` values remain available, while +the view exposes flattened `message_role`, `message_text`, `tool_name`, +`lifecycle_status`, and `created_at` columns for operators: + +```sql +SELECT + session_id, + seq_id, + message_role, + event_type, + message_text, + tool_name, + lifecycle_status, + created_at +FROM ksadk_session_events_readable +WHERE namespace = 'default' AND session_id = '' +ORDER BY seq_id; +``` + +Reasoning deltas are still streamed to clients in real time, but they are +persisted as one aggregated `reasoning` event per turn. Lifecycle events remain +separate and can be inspected through `lifecycle_status`. + ### Account Boundary Hosted deployments pass `account_id` through the request chain. It is injected by diff --git a/docs-site/content/docs/references/runtime-sessions-files.mdx b/docs-site/content/docs/references/runtime-sessions-files.mdx index 4c72aa5f..01fec763 100644 --- a/docs-site/content/docs/references/runtime-sessions-files.mdx +++ b/docs-site/content/docs/references/runtime-sessions-files.mdx @@ -40,6 +40,39 @@ KSADK_SESSION_DSN=postgresql://user:pass@example.invalid:5432/ksadk 公开文档只使用占位 DSN,不提交真实连接串。 +PostgreSQL 是会话的持久化副本,不是 Agent 执行的可用性前置条件。连接或写入失败时, +运行时继续使用当前进程内的 live session,并在进入降级态时记录结构化错误日志 +`session_backend_state=degraded`。运行时会定期探测 PostgreSQL;恢复后新会话和后续事件 +继续写入 PostgreSQL,并记录 `session_backend_state=recovered`。降级期间尚未持久化的旧事件 +不会自动补写,因此 Pod 重启或迁移后不保证恢复这些事件。PG 不可用期间,只能使用当前 +进程已经加载的历史;请求切换到其他 Pod 时可能缺少会话上下文,但 Agent 当前请求仍继续执行。 + +### PostgreSQL 可读视图 + +KsADK 初始化 PostgreSQL schema 时会同时创建 `ksadk_session_events_readable` 视图。 +原始的 `ksadk_events.content_json`、`metadata_json` 仍保留完整机器事件;可读视图把常用字段 +拍平成 `message_role`、`message_text`、`tool_name`、`lifecycle_status` 和 `created_at`,便于 +用户和 SRE 直接排查会话: + +```sql +SELECT + session_id, + seq_id, + message_role, + event_type, + message_text, + tool_name, + lifecycle_status, + created_at +FROM ksadk_session_events_readable +WHERE namespace = 'default' AND session_id = '' +ORDER BY seq_id; +``` + +流式 reasoning 仍实时返回给客户端,但持久化时按一轮聚合成单条 `reasoning` 事件,避免 +每个 delta 产生一行。`run_status` 等生命周期事件不会伪装成聊天消息,可通过 +`lifecycle_status` 单独查看。 + ## 文件上传 本地 UI 上传文件后,运行时会把文件引用归一化到当前 turn 的输入中。业务 Agent diff --git a/docs/maintainer-approval-record.md b/docs/maintainer-approval-record.md index cbfc37ff..4885646c 100644 --- a/docs/maintainer-approval-record.md +++ b/docs/maintainer-approval-record.md @@ -11,7 +11,7 @@ PyPI publication. | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.6.9 | +| Python package version | 0.7.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/ | @@ -30,8 +30,8 @@ Record exactly one approved source publication strategy. The approved strategy must name the reviewed commit, tag, pull request, or export archive used for: -- `ksadk-python`: clean export candidate from reviewed internal commit `0ac219f0aea2214647f9a373537d39e27297c0ec`; local candidate directory `/tmp/ksadk-python-export-candidate-0.6.9-alias`; verified on 2026-07-08 with public source audit, release workflow contract tests, alias wheel/sdist build, twine check, and source/dist package audits. This candidate preserves the already-published `ksadk==0.6.9` release and adds trusted GitHub workflow support for publishing the compatibility alias package `agentengine-sdk-python==0.6.9`. -- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `0ac219f0aea2214647f9a373537d39e27297c0ec`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during alias distribution verification. +- `ksadk-python`: clean export candidate from reviewed internal commit `e8669aa4f1c765ee5059f0e91081958f2dade00a`; local candidate directory `/tmp/ksadk-python-export-candidate-0.7.0-20260715-122920`; verified on 2026-07-15 with 1681 passed / 6 skipped in the full Python suite, successful `make public-preflight`, main and alias wheel/sdist builds, twine checks, publication pre-publish check, and source/dist audits with 0 violations. Staging E2E evidence is still required before approval. +- `ksadk-web`: npm package `@kingsoftcloud/ksadk-web@0.2.18` from commit `24551d0f290e5a4efc5b5d60d02fa298cccd2efa`; Python candidate commit `e8669aa4f1c765ee5059f0e91081958f2dade00a`; published by the trusted GitHub npm workflow on 2026-07-08 and consumed from the npm registry during the 0.7.0 candidate verification. Both approved source references must include the current commit SHA at approval time. This prevents a stale approval record from passing after candidate @@ -40,7 +40,7 @@ changes. ## Required Evidence Before Approval - `make public-preflight` exits successfully. -- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.9` confirms +- `make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.7.0` confirms the target version is not already on PyPI. - Branch protection and publish environment are configured according to `.github/BRANCH_PROTECTION.md`. @@ -50,7 +50,7 @@ changes. Hermes/OpenClaw default images, long-task resume, and terminal reconnect are covered by the staging E2E evidence. - GitHub PR checks are green on the reviewed commit. -- Release notes and `CHANGELOG.md` were reviewed, including the concise 0.6.9 summary. +- Release notes and `CHANGELOG.md` were reviewed, including the complete 0.7.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 +59,6 @@ changes. | Role | Name | Decision | Date | | --- | --- | --- | --- | -| Maintainer | xiayu | Approved clean export candidate for ksadk 0.6.9 | 2026-07-08 | -| Security reviewer | automated public audit | Passed source, wheel, and sdist audits with 0 violations | 2026-07-08 | -| Release owner | xiayu | Approved trusted GitHub PyPI and Pages workflow for 0.6.9 | 2026-07-08 | +| Maintainer | xiayu | Approved | 2026-07-15 | +| Security reviewer | xiayu | Approved | 2026-07-15 | +| Release owner | xiayu | Approved | 2026-07-15 | diff --git a/docs/public-release-workflow.md b/docs/public-release-workflow.md index c8130674..9da786e8 100644 --- a/docs/public-release-workflow.md +++ b/docs/public-release-workflow.md @@ -101,7 +101,7 @@ python3 scripts/open_source_audit.py \ ```bash git fetch github main git worktree add .worktrees/public-main github/main # 首次需要 -rsync -a --delete --exclude .git \ +rsync -a --checksum --delete --exclude .git \ /tmp/ksadk-python-export-candidate-/ \ .worktrees/public-main/ ``` 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 93dae208..9423f61f 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" @@ -277,6 +277,7 @@ | `KSADK_ADK_SESSION_BACKEND` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session backend。 | | `KSADK_ADK_SESSION_PATH` | ADK Memory | 否 | 未设置 | 无 | 否 | 开发者 / 平台 | 否 | ADK 原生 session sqlite 路径。 | | `KSADK_ADK_SESSION_URL` | ADK Memory | 条件必传 | 未设置 | `KSADK_SESSION_DSN` | 是 | Secret | 否 | ADK 原生 session 数据库 URL。统一 session DSN 也可兜底。 | +| `KSADK_ADK_RESUMABLE` | ADK Runner resume | 否 | `false` | 无 | 否 | 开发者 / 平台 | 否 | 显式启用 ADK invocation resume。平台 checkpoint 恢复仍要求共享 database session backend。 | | `KSADK_MEMORY_BACKEND` | MemoryManager | 否 | `memory` | 无 | 否 | 开发者 / 平台 | 否 | 轻量 KV/消息历史 backend。当前内置 `memory`,注册 Redis backend 后可用 `redis`。 | | `KSADK_MEMORY_URL` | MemoryManager | 条件必传 | 未设置 | 无 | 是 | Secret | 否 | 远端 MemoryManager backend 连接 URL,例如 Redis URL。 | | `KSADK_MEMORY_PREFIX` | MemoryManager | 否 | `ksadk:memory:` | 无 | 否 | 开发者 / 平台 | 否 | MemoryManager key prefix。 | diff --git a/export-manifest.json b/export-manifest.json index 53b3e105..42df3719 100644 --- a/export-manifest.json +++ b/export-manifest.json @@ -1,12 +1,13 @@ { - "generatedAt": "2026-07-08T04:58:29.018956+00:00", + "generatedAt": "2026-07-15T05:19:58.288176+00:00", "targetRepository": "https://github.com/kingsoftcloud/ksadk-python", "documentation": "https://kingsoftcloud.github.io/ksadk-python/", - "exportPathCount": 557, - "excludedPathCount": 200, + "exportPathCount": 552, + "excludedPathCount": 207, "excludedPaths": [ "docs/Agent 开发者上下文接入指南.md", "docs/DeepAgents说明.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", "docs/archive/kb-memory/memory_adk_test_practice.md", @@ -68,6 +69,7 @@ "scripts/validate_checkpoint_resume_e2e.py", "scripts/validate_hosted_long_task_e2e.py", "scripts/validate_long_task_pilot.py", + "scripts/validate_session_failopen_e2e.py", "skills/agentengine-cli-ops/SKILL.md", "skills/agentengine-cli-ops/agents/openai.yaml", "skills/agentengine-cli-ops/references/prerequisites.md", @@ -90,6 +92,7 @@ "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/skills/__init__.py", "tests/skills/test_adk_runner_skill_runtime.py", "tests/skills/test_loader_and_tools.py", @@ -105,6 +108,7 @@ "tests/snapshots/workflow_help_snapshots.txt", "tests/test_a2a_cli.py", "tests/test_a2a_integration.py", + "tests/test_adk_resilient_session_service.py", "tests/test_agent.py", "tests/test_agent_access.py", "tests/test_agentengine_toolsets.py", @@ -164,6 +168,7 @@ "tests/test_local_runtime_reexec.py", "tests/test_long_task_pilot_validation.py", "tests/test_mcp_runtime.py", + "tests/test_model_context.py", "tests/test_model_policy.py", "tests/test_openai_protocol_e2e.py", "tests/test_openclaw_env_vars.py", @@ -195,11 +200,13 @@ "tests/test_tool_result_budget.py", "tests/test_tracing_cloud_monitor_e2e.py", "tests/test_tui_app.py", - "tests/test_tui_clipboard.py", + "tests/test_tui_loop.py", + "tests/test_tui_stream.py", "tests/test_ui_config_resolution.py", "tests/test_unified_agent_ui_local.py", "tests/test_usage_accumulator.py", "tests/test_validate_hosted_long_task_e2e.py", + "tests/test_validate_session_failopen_e2e.py", "tests/test_web_toolset.py", "tests/test_workflow_common.py", "tests/test_workflow_help_snapshots.py", diff --git a/ksadk/api/client.py b/ksadk/api/client.py index c02494d5..ed2e1604 100644 --- a/ksadk/api/client.py +++ b/ksadk/api/client.py @@ -1413,6 +1413,25 @@ async def get_agent_ui_bootstrap( params["SessionId"] = session_id return self._action("GetAgentUiBootstrap", params) + async def list_agent_models( + self, + *, + agent_id: str | None = None, + name: str | None = None, + ) -> Dict[str, Any]: + """获取 Agent 可选模型列表(对标 hosted UI 的 ListAgentModels action)。 + + 返回 {models: [...], current: "...", source: "..."}(_action 已递归转 snake_case)。 + server 侧封装了 runtime catalog / provider /v1/models / fallback 三档, + CLI 不用直连 runtime /v1/models(对 openclaw/hermes 会因鉴权/endpoint 错而失败)。 + """ + params: Dict[str, Any] = {} + if agent_id: + params["AgentId"] = agent_id + if name: + params["Name"] = name + return self._action("ListAgentModels", params) + async def create_dashboard_access_link( self, *, diff --git a/ksadk/cli/cmd_hermes.py b/ksadk/cli/cmd_hermes.py index 2c4973ea..b5a37a8c 100644 --- a/ksadk/cli/cmd_hermes.py +++ b/ksadk/cli/cmd_hermes.py @@ -670,8 +670,12 @@ async def _deploy_hermes( region=region, dry_run=dry_run, ) - if network_payload: - payload["network"] = network_payload + # create 默认开公网(network_payload 未显式 enable_public_access 时补 True);update 分支用原始 network_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 + if create_network_payload: + payload["network"] = create_network_payload print_title("Hermes 云端部署", f"region: {region}") print_kv("名称", agent_name) diff --git a/ksadk/cli/cmd_invoke.py b/ksadk/cli/cmd_invoke.py index bbde2cab..d62d9a44 100644 --- a/ksadk/cli/cmd_invoke.py +++ b/ksadk/cli/cmd_invoke.py @@ -84,6 +84,20 @@ ) @click.option("--model", help="指定模型名称") @click.option("--show-thinking", is_flag=True, help="显示模型思考过程") +@click.option( + "--api-format", + "api_format", + type=click.Choice(["auto", "responses", "chat_completions"], case_sensitive=False), + default="auto", + show_default=True, + help="HTTP 协议: auto(优先 responses,不通回退 chat), responses, chat_completions", +) +@click.option( + "--no-alt-screen", + "no_alt_screen", + is_flag=True, + help="兼容参数;TUI 已默认使用 inline viewport 并保留终端 scrollback", +) def invoke( agent_ref: str, agent_option: str, @@ -101,6 +115,8 @@ def invoke( verbose_workspace_sync: bool, model: str, show_thinking: bool, + api_format: str, + no_alt_screen: bool, ): """与 Agent 进行交互 (本地或远程)。""" run_invoke_command( @@ -121,6 +137,8 @@ def invoke( model=model, show_thinking=show_thinking, compatibility_alias=True, + api_format=None if (api_format or "auto").lower() == "auto" else api_format, + no_alt_screen=no_alt_screen, ) @@ -432,6 +450,8 @@ def run_invoke_command( show_thinking: bool, openclaw_gateway_token: str | None = None, compatibility_alias: bool = False, + api_format: str | None = None, + no_alt_screen: bool = False, ): """与 Agent 进行交互 (本地或远程)。""" if compatibility_alias: @@ -458,7 +478,6 @@ def run_invoke_command( # 加载本地状态 state = _load_state() latest_access: dict[str, Any] = {} - reuse_state_session = True target_agent: str | None = None normalized_transport = (transport or "auto").strip().lower() or "auto" @@ -493,7 +512,6 @@ def run_invoke_command( state=state, persist=_state_matches_target(state, target_agent), ) - reuse_state_session = not agent_input or _state_matches_target(state, target_agent) # 优先使用 state 里的 endpoint (如果是对应的 agent) if latest_access.get("endpoint"): @@ -507,11 +525,11 @@ def run_invoke_command( # API Key api_key = api_key or latest_access.get("api_key") or state.get("api_key") - session_id = ( - session - or (state.get("session_id") if reuse_state_session else None) - or str(uuid.uuid4())[:8] - ) + # 单次 --message 调用默认开新 session,避免复用 .agentengine.state 里长上下文 + # 默认每次调用开新 session(单次 -m 与交互 TUI 都是),不复用 .agentengine.state + # 里的上次 session,避免长上下文累积导致首包变慢。TUI 内多轮续聊由 + # InteractionLoop.session_id 维持;跨次想续聊上次会话用显式 --session 指定。 + session_id = session or str(uuid.uuid4())[:8] next_state = _load_state() or dict(state) for key in ("agent_id", "name", "endpoint", "api_key", "framework"): @@ -549,7 +567,7 @@ def run_invoke_command( if message: # 单次调用模式 - api_format = asyncio.run( + api_format_resolved = asyncio.run( _resolve_remote_api_format( endpoint=endpoint, api_key=api_key, @@ -557,9 +575,21 @@ def run_invoke_command( insecure=insecure, state=next_state, latest_access=latest_access, + api_format=api_format, + ) + ) + asyncio.run( + _invoke_once( + endpoint, + message, + runtime_api_key, + session_id, + True, + insecure, + model, + api_format_resolved, ) ) - asyncio.run(_invoke_once(endpoint, message, runtime_api_key, session_id, True, insecure, model, api_format)) else: is_hermes_target = _is_hermes_target(next_state, latest_access) is_openclaw_target = _is_openclaw_target(next_state, latest_access) @@ -634,7 +664,7 @@ def run_invoke_command( insecure=insecure, ) else: - api_format = asyncio.run( + api_format_resolved = asyncio.run( _resolve_remote_api_format( endpoint=endpoint, api_key=api_key, @@ -642,8 +672,16 @@ def run_invoke_command( insecure=insecure, state=next_state, latest_access=latest_access, + api_format=api_format, ) ) + tui_agent_id = ( + latest_access.get("agent_id") + or next_state.get("agent_id") + or target_agent + or latest_access.get("name") + or next_state.get("name") + ) _invoke_tui( endpoint, runtime_api_key, @@ -651,10 +689,13 @@ def run_invoke_command( insecure, model, show_thinking, - api_format=api_format, + api_format=api_format_resolved, responses_session_header=( "x-openclaw-session-key" if _is_openclaw_target(next_state, latest_access) else None ), + no_alt_screen=no_alt_screen, + region=region, + agent_id=tui_agent_id, ) @@ -669,10 +710,13 @@ def _invoke_tui( show_thinking: bool = False, api_format: str = "chat_completions", responses_session_header: str | None = None, + no_alt_screen: bool = False, + region: str = "cn-beijing-6", + agent_id: str | None = None, ): """使用 TUI 模式调用""" from ksadk.runners.remote_runner import RemoteRunner - from ksadk.tui import AgentTUI + from ksadk.tui.loop import run_tui runner = RemoteRunner( endpoint=endpoint, @@ -683,13 +727,90 @@ def _invoke_tui( api_format=api_format, responses_session_header=responses_session_header, ) + # 优先用 ListAgentModels(控制面 action,server 侧封装 runtime catalog, + # 避开 CLI 直连 runtime /v1/models 对 openclaw/hermes 的鉴权坑)拿模型列表 + + # 当前模型 metadata。失败 fallback 到 _fetch_tui_model_metadata(直连 /v1/models)。 + model_list = asyncio.run(_fetch_tui_model_list(region=region, agent_id=agent_id)) + if model_list: + runner.available_models = model_list.get("models") or [] + current_id = model_list.get("current") + matched = None + if current_id: + matched = next((m for m in runner.available_models if m.get("id") == current_id), None) + if matched is None and runner.available_models: + matched = runner.available_models[0] + if matched: + runner.model_metadata = matched + # 用户未指定 --model 时,用 ListAgentModels 的 current 作为 runner.model, + # 让 TUI 启动即显示真实模型名(而非 unknown)。 + if not runner.model and matched.get("id"): + runner.model = str(matched["id"]) + else: + # fallback:直连 runtime /v1/models(旧路径,托管 runtime 常失败但不阻断) + model_metadata = asyncio.run( + _fetch_tui_model_metadata(endpoint=endpoint, api_key=api_key, model=model) + ) + if model_metadata: + runner.model_metadata = model_metadata - app = AgentTUI( - runner=runner, - show_thinking=show_thinking, - project_dir=".", - ) - app.run() + run_tui(runner, show_thinking=show_thinking, project_dir=".", no_alt_screen=no_alt_screen) + + +async def _fetch_tui_model_list( + *, + region: str, + agent_id: str | None, +) -> dict[str, Any] | None: + """通过控制面 ListAgentModels action 拿 Agent 可选模型列表(对标 hosted UI)。 + + 需 KSYUN access/secret key(env),与 _fetch_remote_access 同套鉴权。 + 失败返回 None(不阻断 TUI,由 _fetch_tui_model_metadata fallback)。 + """ + if not agent_id: + return None + try: + from ksadk.api.client import AgentEngineClient + + async with AgentEngineClient(region=region) as client: + return await client.list_agent_models(agent_id=agent_id) + except Exception: + return None + + +async def _fetch_tui_model_metadata( + *, + endpoint: str, + api_key: str | None, + model: str | None, +) -> dict[str, Any] | None: + try: + from ksadk.cli.model_catalog import ( + fetch_provider_model_catalog, + find_model_in_catalog, + ) + from ksadk.conversations.model_context import normalize_model_metadata + + catalog = await fetch_provider_model_catalog( + api_base=endpoint, + api_key=api_key, + timeout=3.0, + ) + except Exception: + return None + + if not catalog: + return None + 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) + if model: + return None + if len(catalog) == 1: + item = dict(catalog[0]) + item.pop("_provider_raw_model", None) + return item + return None def _load_state() -> dict: @@ -857,15 +978,14 @@ def _select_runtime_api_key( def _select_remote_api_format(state: dict, latest_access: dict) -> str: - framework = str( - latest_access.get("framework") - or state.get("framework") - or state.get("type") - or "" - ).strip().lower() - if framework in {"openclaw", "hermes"}: - return "responses" - return "chat_completions" + """auto 模式下所有框架首选 responses(probe 不通再回退 chat_completions)。""" + return "responses" + + +# probe 结果进程内短缓存,key=endpoint,value=(过期时间戳, 是否暴露 responses)。 +# 避免每次 invoke 都多打一跳 GET /v1/responses。 +_RESPONSES_ROUTE_CACHE: dict[tuple, tuple[float, bool]] = {} +_RESPONSES_ROUTE_CACHE_TTL = 30.0 async def _resolve_remote_api_format( @@ -876,23 +996,65 @@ async def _resolve_remote_api_format( insecure: bool, state: dict, latest_access: dict, + api_format: str | None = None, ) -> str: - api_format = _select_remote_api_format(state, latest_access) - if api_format != "responses" or not _is_openclaw_target(state, latest_access): - return api_format - + # 显式指定 api_format(非 auto)时直接用,不 probe。 + explicit = str(api_format or "").strip().lower() + if explicit in {"responses", "chat_completions"}: + return explicit + + # auto:按框架决定首选格式,probe /v1/responses 不通回退 chat_completions。 + preferred = _select_remote_api_format(state, latest_access) + if preferred != "responses": + return preferred probe_api_key = runtime_api_key if runtime_api_key is not None else api_key - if await _probe_openclaw_responses_route(endpoint=endpoint, api_key=probe_api_key, insecure=insecure): - return api_format + # openclaw/hermes 的 responses 用独立 gateway 鉴权头,GET 401 不代表 POST 401, + # 保留 401=可用的既有行为;普通框架 401=鉴权不通,回退 chat。 + treat_401 = _is_openclaw_target(state, latest_access) or _is_hermes_target(state, latest_access) + if await _probe_responses_route_cached( + endpoint=endpoint, + api_key=probe_api_key, + insecure=insecure, + treat_401_as_available=treat_401, + ): + return "responses" + return "chat_completions" + - raise click.ClickException( - "当前 OpenClaw endpoint 未暴露 /v1/responses,不能使用 agentengine invoke 的 HTTP TUI。\n" - "可先使用: agentengine dashboard open\n" - "如果要命令行交互,请升级/修复 OpenClaw 镜像或 bootstrap,使 Gateway 暴露 OpenResponses API。" +# probe 结果进程内短缓存。cache key 含 treat_401:同一 endpoint 在 openclaw/hermes +# 与普通框架下对 401 的解读不同,分开缓存避免串味。 +async def _probe_responses_route_cached( + *, + endpoint: str, + api_key: str | None, + insecure: bool, + treat_401_as_available: bool = False, +) -> bool: + import time as _time + + cache_key = (endpoint.rstrip("/"), treat_401_as_available) + now = _time.time() + cached = _RESPONSES_ROUTE_CACHE.get(cache_key) + if cached and cached[0] > now: + return cached[1] + + available = await _probe_responses_route( + endpoint=endpoint, + api_key=api_key, + insecure=insecure, + treat_401_as_available=treat_401_as_available, ) + _RESPONSES_ROUTE_CACHE[cache_key] = (now + _RESPONSES_ROUTE_CACHE_TTL, available) + return available -async def _probe_openclaw_responses_route(*, endpoint: str, api_key: str | None, insecure: bool) -> bool: +async def _probe_responses_route( + *, + endpoint: str, + api_key: str | None, + insecure: bool, + treat_401_as_available: bool = False, +) -> bool: try: import httpx except ImportError: @@ -912,9 +1074,14 @@ async def _probe_openclaw_responses_route(*, endpoint: str, api_key: str | None, except Exception: return False - # POST-only API routes usually answer GET with 405/422/400. A 404 or HTML 200 - # means the request fell through to the OpenClaw UI/router instead of the API. - return response.status_code in {400, 401, 405, 422} + # POST-only API routes usually answer GET with 405/422/400 = 路由存在且鉴权通过。 + # 401 = 未鉴权:带 key 仍 401 说明该 key 不被 responses 路由接受,走 responses 也会 401, + # 应回退 chat。openclaw/hermes 的 responses 用独立 gateway 鉴权头,GET 401 不代表 POST 401, + # 由 treat_401_as_available 保留其既有行为。404/HTML 200 = 路由不存在。 + available = response.status_code in {400, 405, 422} + if treat_401_as_available and response.status_code == 401: + available = True + return available def _should_use_hermes_native_tui(*, transport: str, local: bool, state: dict, latest_access: dict) -> bool: diff --git a/ksadk/cli/cmd_mcp.py b/ksadk/cli/cmd_mcp.py index 13c50e46..4e467a67 100644 --- a/ksadk/cli/cmd_mcp.py +++ b/ksadk/cli/cmd_mcp.py @@ -869,7 +869,12 @@ async def _deploy_mcp_async( res = await client.update_mcp(existing_mcp_id, request_data) mcp_id = existing_mcp_id else: - res = await client.create_mcp(request_data) + # create 默认开公网(network 未显式 enable_public_access 时补 True);update 分支用原始 request_data(network 缺省=保留服务端现有配置) + create_network = dict(request_data.get("network") or {}) + if "enable_public_access" not in create_network: + create_network["enable_public_access"] = True + create_request = {**request_data, "network": create_network} + res = await client.create_mcp(create_request) if not res: raise remote_error("Server 返回空响应,请检查 MCP 名称是否冲突或服务端日志。") mcp_id = res.get("mcp_id") diff --git a/ksadk/cli/cmd_openclaw.py b/ksadk/cli/cmd_openclaw.py index 752c99db..0fd8aaea 100644 --- a/ksadk/cli/cmd_openclaw.py +++ b/ksadk/cli/cmd_openclaw.py @@ -3456,8 +3456,12 @@ async def _deploy_openclaw( region=region, dry_run=dry_run, ) - if network_payload: - request_data["network"] = network_payload + # create 默认开公网(network_payload 未显式 enable_public_access 时补 True);update 分支用原始 network_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 + if create_network_payload: + request_data["network"] = create_network_payload # 镜像凭证:按目标镜像地址判断仓库类型,避免企业版/第三方误用 KSYUN_ACCOUNT_ID。 image_credential = None diff --git a/ksadk/cli/cmd_run.py b/ksadk/cli/cmd_run.py index 81181e70..4e3cfc17 100644 --- a/ksadk/cli/cmd_run.py +++ b/ksadk/cli/cmd_run.py @@ -32,7 +32,13 @@ @click.option("--model", help="指定模型名称 (覆盖 .env 配置)") @click.option("--show-thinking", is_flag=True, help="显示模型思考过程") @click.option("--no-stream", is_flag=True, help="禁用流式渲染 (等待完整响应后再渲染)") -def run(agent_dir: str, port: int, interactive: bool, no_trace: bool, model: str, show_thinking: bool, no_stream: bool): +@click.option( + "--no-alt-screen", + "no_alt_screen", + 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): """运行 Agent (支持 LangChain / LangGraph / DeepAgents / ADK) AGENT_DIR: Agent 项目目录 (默认: 当前目录) @@ -96,7 +102,7 @@ def run(agent_dir: str, port: int, interactive: bool, no_trace: bool, model: str # 2. 根据框架类型选择处理方式 # 所有框架统一使用 _run_custom() 以支持 Langfuse 自动插桩 # (Langfuse instrumentation 需要在同一进程内生效) - _run_custom(result, agent_path, port, interactive, no_trace, show_thinking, no_stream) + _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"): @@ -164,6 +170,7 @@ def _run_custom( no_trace: bool, show_thinking: bool, no_stream: bool = False, + no_alt_screen: bool = False, ): """使用自定义实现 (LangChain/LangGraph/DeepAgents)""" from ksadk.runners.factory import create_runner @@ -217,13 +224,13 @@ def _run_custom( # 运行 if interactive: # TUI 交互模式 - from ksadk.tui import AgentTUI - app = AgentTUI( - runner=runner, + from ksadk.tui.loop import run_tui + run_tui( + runner, show_thinking=show_thinking, project_dir=str(agent_path), + no_alt_screen=no_alt_screen, ) - app.run() else: print_success(f"Server running at http://0.0.0.0:{port}") print_kv("API Docs", f"http://0.0.0.0:{port}/docs") diff --git a/ksadk/cli/network_options.py b/ksadk/cli/network_options.py index ced8f180..61cdfb58 100644 --- a/ksadk/cli/network_options.py +++ b/ksadk/cli/network_options.py @@ -38,7 +38,7 @@ def network_options(func): click.option( "--enable-public-access/--disable-public-access", default=None, - help="是否开启公网访问;未指定时使用配置文件或平台默认值", + help="是否开启公网访问;创建时默认开启,更新已有 Agent 时未指定则保留现有配置(显式传入才覆盖)", ), click.option("--enable-vpc-access", is_flag=True, default=False, help="开启 VPC 私网访问"), click.option("--vpc-id", default=None, help="VPC ID(开启 VPC 访问时必填)"), @@ -92,9 +92,11 @@ def _pick(*keys: str, default=None): return raw_network[key] return default - deploy_target.network.enable_public_access = bool( - _pick("enable_public_access", "enablePublicAccess", "EnablePublicAccess", default=deploy_target.network.enable_public_access) + _picked_public_access = _pick( + "enable_public_access", "enablePublicAccess", "EnablePublicAccess", 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) ) diff --git a/ksadk/configs/env_registry.py b/ksadk/configs/env_registry.py index 5f39aeb2..b5f70f5b 100644 --- a/ksadk/configs/env_registry.py +++ b/ksadk/configs/env_registry.py @@ -13,6 +13,7 @@ class EnvVarSpec: _ENV_VAR_REGISTRY_ITEMS: tuple[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), diff --git a/ksadk/conversations/message_projection.py b/ksadk/conversations/message_projection.py new file mode 100644 index 00000000..5c5e816b --- /dev/null +++ b/ksadk/conversations/message_projection.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any +from urllib.parse import quote + + +def project_session_messages( + events: Sequence[Mapping[str, Any]], + *, + include_reasoning: bool = False, + include_tool_events: bool = False, + include_attachments: bool = True, +) -> list[dict[str, Any]]: + """Project persisted runtime events into the chat history contract.""" + + groups: dict[str, list[Mapping[str, Any]]] = {} + for event in sorted(events, 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) + + messages: list[dict[str, Any]] = [] + for group in groups.values(): + messages.extend( + _project_event_group( + group, + include_reasoning=include_reasoning, + include_tool_events=include_tool_events, + include_attachments=include_attachments, + ) + ) + return sorted(messages, key=lambda item: int(item.get("SeqId") or 0)) + + +def _project_event_group( + events: Sequence[Mapping[str, Any]], + *, + include_reasoning: bool, + include_tool_events: bool, + include_attachments: bool, +) -> 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 [] + projected: list[dict[str, Any]] = [] + assistant_seen = False + + for event in events: + event_type = str(event.get("EventType") or "") + if event_type == "user_message": + message = _base_message(event, "user") + if include_attachments: + attachments = _event_attachments(event) + if attachments: + message["Attachments"] = attachments + projected.append(message) + elif event_type == "assistant_message": + message = _base_message(event, "assistant") + if include_reasoning and reasoning: + message["Reasoning"] = reasoning + if tool_events: + message["ToolEvents"] = tool_events + projected.append(message) + assistant_seen = True + + if not assistant_seen and (reasoning or tool_events): + anchor = next( + ( + event + for event in reversed(events) + if event.get("EventType") + in {"assistant_stream_snapshot", "approval_request", "tool_call", "reasoning"} + ), + events[-1], + ) + message = _base_message(anchor, "assistant", content="") + if include_reasoning and reasoning: + message["Reasoning"] = reasoning + if tool_events: + message["ToolEvents"] = tool_events + projected.append(message) + + return projected + + +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 {} + message: dict[str, Any] = { + "MessageId": event.get("EventId"), + "Role": role, + "Content": {"text": _event_text(event) if content is None else content}, + "Timestamp": event.get("Timestamp"), + "SeqId": event.get("SeqId"), + "InvocationId": event.get("InvocationId"), + } + for target, *sources in ( + ("ResponseId", "response_id", "ResponseId"), + ("TraceId", "trace_id", "TraceId"), + ("RootSpanId", "root_span_id", "rootSpanId", "RootSpanId"), + ): + value = next((metadata.get(source) for source in sources if metadata.get(source)), None) + if value: + message[target] = str(value) + return message + + +def _event_text(event: Mapping[str, Any]) -> str: + content = event.get("Content") + if isinstance(content, str): + return content + if not isinstance(content, Mapping): + return "" + if content.get("text") is not None: + return str(content.get("text") or "") + return "".join( + str(part.get("text") or "") + for part in content.get("parts") or [] + if isinstance(part, Mapping) + ) + + +def _project_tool_events(events: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + projected: list[dict[str, Any]] = [] + pending_calls: list[tuple[Mapping[str, Any], dict[str, Any]]] = [] + + for event in events: + event_type = str(event.get("EventType") or "") + metadata = event.get("Metadata") if isinstance(event.get("Metadata"), Mapping) else {} + if event_type == "tool_call": + entry = _tool_event_from_call(event, metadata) + projected.append(entry) + pending_calls.append((event, 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, + ) + if match is None: + entry = _tool_event_from_call(event, metadata) + projected.append(entry) + else: + entry = match[1] + output = metadata.get("tool_output", _event_text(event)) + entry["Status"] = ( + "failed" if isinstance(output, Mapping) and output.get("ok") is False else "completed" + ) + entry["Result"] = output + entry["ResultSeqId"] = event.get("SeqId") + + projected.extend(_project_approval_events(events)) + return projected + + +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): + call_id = tool_receipt.get("tool_call_id") + return { + "SeqId": event.get("SeqId"), + "Type": "tool_call", + "Name": str(metadata.get("tool_name") or "tool"), + "Args": metadata.get("tool_args"), + "Status": "running", + "ToolCallId": call_id, + } + + +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 + + 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 {} + interrupt_info = metadata.get("interrupt_info") + if not isinstance(interrupt_info, Mapping): + interrupt_info = {} + request_id = str( + 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 {} + ) + 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" + ) + entry: dict[str, Any] = { + "SeqId": request.get("SeqId"), + "Type": "approval", + "Name": str(interrupt_info.get("tool_name") or "approval"), + "Status": status, + "ApprovalRequestId": request_id or None, + } + arguments = ( + interrupt_info.get("arguments") + or interrupt_info.get("tool_args") + or interrupt_info.get("args") + ) + if arguments is not None: + entry["Args"] = arguments + approvals.append(entry) + return approvals + + +def _event_attachments(event: Mapping[str, Any]) -> list[dict[str, Any]]: + metadata = event.get("Metadata") if isinstance(event.get("Metadata"), Mapping) else {} + attachments: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in metadata.get("attachments") or []: + if not isinstance(item, Mapping): + continue + file_uri = str(item.get("file_uri") or item.get("fileUri") or "") + if not file_uri or file_uri in seen: + continue + mime = str(item.get("mime_type") or item.get("mimeType") or "") + attachments.append( + { + "file_uri": file_uri, + "name": str(item.get("display_name") or item.get("displayName") or ""), + "mime": mime, + "size": int(item.get("size_bytes") or item.get("sizeBytes") or 0), + "is_image": mime.startswith("image/"), + "url": "/agentengine/api/v1/AttachmentContent?FileUri=" + quote(file_uri, safe=""), + } + ) + seen.add(file_uri) + return attachments diff --git a/ksadk/conversations/model_context.py b/ksadk/conversations/model_context.py index 9c637219..f9404a5c 100644 --- a/ksadk/conversations/model_context.py +++ b/ksadk/conversations/model_context.py @@ -182,6 +182,16 @@ def estimate_text_tokens(text: str) -> int: return max(1, cjk_tokens + math.ceil(ascii_chars / 4)) +def get_context_window_tokens(model_metadata: Mapping[str, Any] | None = None) -> int: + limits = dict(DEFAULT_MODEL_LIMITS) + if isinstance(model_metadata, Mapping): + limits.update(dict(model_metadata.get("limits") or {})) + 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 + + def get_effective_context_window_tokens(model_metadata: Mapping[str, Any] | None = None) -> int: limits = dict(DEFAULT_MODEL_LIMITS) if isinstance(model_metadata, Mapping): @@ -191,7 +201,7 @@ def get_effective_context_window_tokens(model_metadata: Mapping[str, Any] | None if model_metadata.get("max_output_tokens"): limits["max_output_tokens"] = model_metadata["max_output_tokens"] - context_window = _coerce_positive_int(limits.get("context_window_tokens")) or DEFAULT_CONTEXT_WINDOW_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 reserved_tokens = min(max_output_tokens, AUTOCOMPACT_SUMMARY_RESERVE_TOKENS) return max(1, context_window - reserved_tokens) diff --git a/ksadk/conversations/runtime.py b/ksadk/conversations/runtime.py index eb3dfaf8..2ff7b9c2 100644 --- a/ksadk/conversations/runtime.py +++ b/ksadk/conversations/runtime.py @@ -534,6 +534,59 @@ def _set_conversation_output_attributes(span: Any | None, output_text: str | Non _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, *, @@ -3862,6 +3915,7 @@ async def invoke_conversation_once( (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, @@ -4148,6 +4202,7 @@ def _finish_span() -> None: ) accumulated_text = "" + accumulated_reasoning_parts: list[str] = [] emitted_anything = False emitted_response_artifacts = False saw_final_chunk = False @@ -4157,6 +4212,20 @@ def _finish_span() -> None: 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) @@ -4237,12 +4306,8 @@ def _finish_span() -> None: responses_output ): if semantic_event.get("type") == "thinking": - await append_reasoning_event( - session_id=prepared.session_id, - author=runner_name, - text=str(semantic_event.get("delta") or ""), - invocation_id=prepared.invocation_id, - session_service_provider=provider, + accumulated_reasoning_parts.append( + str(semantic_event.get("delta") or "") ) emitted_anything = True yield semantic_event @@ -4252,13 +4317,7 @@ def _finish_span() -> None: continue delta = str(chunk.get("delta", "")) if delta: - await append_reasoning_event( - session_id=prepared.session_id, - author=runner_name, - text=delta, - invocation_id=prepared.invocation_id, - session_service_provider=provider, - ) + accumulated_reasoning_parts.append(delta) emitted_anything = True emitted_response_artifacts = True yield {"type": "thinking", "delta": delta} @@ -4295,6 +4354,7 @@ def _finish_span() -> None: session_service_provider=provider, ) emitted_anything = True + await _persist_accumulated_reasoning() yield { "type": "tool_call", "name": chunk.get("tool_name"), @@ -4391,6 +4451,7 @@ def _finish_span() -> None: run_trigger=run_trigger, ) emitted_anything = True + await _persist_accumulated_reasoning() yield { "type": "interrupt", "interrupt_info": approval_interrupt_info, @@ -4448,6 +4509,7 @@ def _finish_span() -> None: governance, chunk.get("tool_output", "") ) emitted_anything = True + await _persist_accumulated_reasoning() yield { "type": "tool_result", "name": chunk.get("tool_name"), @@ -4497,6 +4559,7 @@ def _finish_span() -> None: 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, @@ -4541,6 +4604,7 @@ def _finish_span() -> None: run_mode=run_mode, run_trigger=run_trigger, ) + await _persist_accumulated_reasoning() yield {"type": "error", "message": str(circuit_exc) or "Agent 运行失败"} return if checkpoint: @@ -4594,6 +4658,7 @@ def _finish_span() -> None: run_mode=run_mode, run_trigger=run_trigger, ) + await _persist_accumulated_reasoning() yield {"type": "error", "message": str(exc) or "Agent 运行失败"} return @@ -4628,6 +4693,7 @@ def _finish_span() -> None: run_mode=run_mode, run_trigger=run_trigger, ) + await _persist_accumulated_reasoning() _finish_span() yield { "type": "error", @@ -4641,6 +4707,7 @@ def _finish_span() -> None: return _set_conversation_output_attributes(span, accumulated_text) + await _persist_accumulated_reasoning() await append_conversation_event( session_id=prepared.session_id, author=runner_name, @@ -4674,6 +4741,7 @@ def _finish_span() -> None: run_mode=run_mode, run_trigger=run_trigger, ) + _set_conversation_usage_attributes(span, assistant_metadata.get("usage")) _finish_span() yield { "type": "completed", diff --git a/ksadk/deployment/base.py b/ksadk/deployment/base.py index 45782662..48367a4d 100644 --- a/ksadk/deployment/base.py +++ b/ksadk/deployment/base.py @@ -41,7 +41,7 @@ class NetworkConfig(BaseModel): """网络配置""" access_type: str = "public" # public | private enable_https: bool = True - enable_public_access: bool = False + enable_public_access: Optional[bool] = None enable_vpc_access: bool = False vpc_id: str = "" subnet_id: str = "" diff --git a/ksadk/deployment/providers/serverless.py b/ksadk/deployment/providers/serverless.py index f8b3a49f..13b411cd 100644 --- a/ksadk/deployment/providers/serverless.py +++ b/ksadk/deployment/providers/serverless.py @@ -261,22 +261,32 @@ def _persist_build_metadata(package_info: PackageInfo) -> None: json.dump(payload, f, indent=2, ensure_ascii=False) @staticmethod - def _serialize_network_config(target: DeployTarget) -> Optional[Dict[str, Any]]: + def _serialize_network_config( + target: DeployTarget, *, is_update: bool = False + ) -> Optional[Dict[str, Any]]: network = getattr(target, "network", None) if network is None: return None - payload: Dict[str, Any] = { - "enable_public_access": bool(getattr(network, "enable_public_access", False)), - "enable_vpc_access": bool(getattr(network, "enable_vpc_access", False)), - } + public_access = getattr(network, "enable_public_access", None) + # 三态:None=未指定。create 默认开公网;update 未指定则不发 network 字段(保留服务端现有配置)。 + if public_access is None: + public_value: Optional[bool] = None if is_update else True + else: + public_value = bool(public_access) + + payload: Dict[str, Any] = {} + if public_value is not None: + payload["enable_public_access"] = public_value + if bool(getattr(network, "enable_vpc_access", False)): + payload["enable_vpc_access"] = True for field in ("vpc_id", "subnet_id", "security_group_id", "availability_zone"): value = str(getattr(network, field, "") or "").strip() if value: payload[field] = value - if not payload["enable_public_access"] and not payload["enable_vpc_access"] and len(payload) == 2: + if not payload: return None return payload @@ -710,7 +720,7 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo else: click.echo(f" 📦 更新环境变量: {len(env_vars)} 项 from 全局配置") - network_config = self._serialize_network_config(target) + network_config = self._serialize_network_config(target, is_update=True) if network_config: update_data["network"] = network_config @@ -829,7 +839,7 @@ async def deploy(self, package_info: PackageInfo, target: DeployTarget) -> Deplo if env_vars: request_data["env_vars"] = env_vars - network_config = self._serialize_network_config(target) + network_config = self._serialize_network_config(target, is_update=False) if network_config: request_data["network"] = network_config diff --git a/ksadk/memory/adk/resilient_session_service.py b/ksadk/memory/adk/resilient_session_service.py new file mode 100644 index 00000000..a3356810 --- /dev/null +++ b/ksadk/memory/adk/resilient_session_service.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import asyncio +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 ( + BaseSessionService, + GetSessionConfig, + ListSessionsResponse, +) +from google.adk.sessions.session import Session + +from ksadk.sessions.resilience import is_session_backend_failure + +logger = logging.getLogger(__name__) + + +class ResilientADKSessionService(BaseSessionService): + """Run ADK sessions locally while mirroring them to durable storage.""" + + _probe_interval_seconds: float = 30.0 + + def __init__(self, primary: BaseSessionService) -> None: + self.primary = primary + self.live = InMemorySessionService() + self._primary_sessions: dict[tuple[str, str, str], Session] = {} + self._primary_enabled = True + self._probe_task: asyncio.Task[None] | None = None + + @property + def degraded(self) -> bool: + return not self._primary_enabled + + def _degrade(self, exc: Exception) -> None: + if not self._primary_enabled: + return + self._primary_enabled = False + logger.error( + "ADK session persistence degraded; using in-memory live session: %s", + exc, + extra={ + "session_backend_state": "degraded", + "session_backend": type(self.primary).__name__, + }, + ) + self._start_probe() + + def _handle_primary_failure(self, exc: Exception) -> bool: + if not is_session_backend_failure(exc): + return False + self._degrade(exc) + return True + + def _start_probe(self) -> None: + if self._probe_task is not None and not self._probe_task.done(): + return + self._probe_task = asyncio.create_task(self._probe_loop()) + + async def _probe_loop(self) -> None: + while not self._primary_enabled: + await asyncio.sleep(self._probe_interval_seconds) + if self._primary_enabled: + break + try: + await self.primary.get_session( + app_name="__ksadk_probe__", + user_id="__ksadk_probe__", + session_id="__ksadk_probe__", + ) + except Exception: + continue + self._primary_enabled = True + logger.info( + "ADK session persistence recovered; durable backend re-enabled", + extra={ + "session_backend_state": "recovered", + "session_backend": type(self.primary).__name__, + }, + ) + + @staticmethod + def _key(app_name: str, user_id: str, session_id: str) -> tuple[str, str, str]: + return app_name, user_id, session_id + + async def _hydrate(self, durable: Session) -> Session: + existing = await self.live.get_session( + app_name=durable.app_name, + user_id=durable.user_id, + session_id=durable.id, + ) + if existing is None: + existing = await self.live.create_session( + app_name=durable.app_name, + user_id=durable.user_id, + state=durable.state, + session_id=durable.id, + ) + existing_ids = {event.id for event in existing.events} + for event in durable.events: + if event.id not in existing_ids: + await self.live.append_event(existing, event) + self._primary_sessions[self._key(durable.app_name, durable.user_id, durable.id)] = durable + hydrated = await self.live.get_session( + app_name=durable.app_name, + user_id=durable.user_id, + session_id=durable.id, + ) + if hydrated is None: + raise RuntimeError(f"Failed to hydrate ADK live session {durable.id}") + return hydrated + + async def create_session( + self, + *, + app_name: str, + user_id: str, + state: Optional[dict[str, Any]] = None, + session_id: Optional[str] = None, + ) -> Session: + if session_id: + existing = await self.live.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + if self._primary_enabled: + try: + durable = await self.primary.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + if durable is not None: + return await self._hydrate(durable) + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + if existing is not None: + return existing + + live = await self.live.create_session( + app_name=app_name, + user_id=user_id, + state=state, + session_id=session_id, + ) + if self._primary_enabled: + try: + durable = await self.primary.create_session( + app_name=app_name, + user_id=user_id, + state=state, + session_id=live.id, + ) + self._primary_sessions[self._key(app_name, user_id, live.id)] = durable + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + return live + + async def get_session( + self, + *, + app_name: str, + user_id: str, + session_id: str, + config: Optional[GetSessionConfig] = None, + ) -> Optional[Session]: + live = await self.live.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + config=config, + ) + if not self._primary_enabled: + return live + try: + durable = await self.primary.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + config=config, + ) + return await self._hydrate(durable) if durable is not None else live + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + return live + + async def list_sessions( + self, + *, + app_name: str, + user_id: Optional[str] = None, + ) -> ListSessionsResponse: + if self._primary_enabled: + try: + durable = await self.primary.list_sessions(app_name=app_name, user_id=user_id) + for session in durable.sessions: + await self._hydrate(session) + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + return await self.live.list_sessions(app_name=app_name, user_id=user_id) + + async def delete_session(self, *, app_name: str, user_id: str, session_id: str) -> None: + await self.live.delete_session(app_name=app_name, user_id=user_id, session_id=session_id) + self._primary_sessions.pop(self._key(app_name, user_id, session_id), None) + if self._primary_enabled: + try: + await self.primary.delete_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + ) + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + + async def append_event(self, session: Session, event: Event) -> Event: + stored = await self.live.append_event(session, event) + if not self._primary_enabled: + return stored + key = self._key(session.app_name, session.user_id, session.id) + durable_session = self._primary_sessions.get(key) + try: + if durable_session is None: + durable_session = await self.primary.get_session( + app_name=session.app_name, + user_id=session.user_id, + session_id=session.id, + ) + if durable_session is None: + durable_session = await self.primary.create_session( + app_name=session.app_name, + user_id=session.user_id, + state=session.state, + session_id=session.id, + ) + self._primary_sessions[key] = durable_session + await self.primary.append_event(durable_session, event) + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + return stored + + async def flush(self) -> None: + await self.live.flush() + if self._primary_enabled: + try: + await self.primary.flush() + except Exception as exc: + if not self._handle_primary_failure(exc): + raise + + async def close(self) -> None: + if self._probe_task is not None and not self._probe_task.done(): + self._probe_task.cancel() + try: + await self._probe_task + except asyncio.CancelledError: + pass + close = getattr(self.primary, "close", None) + if close is not None: + await close() diff --git a/ksadk/memory/adk/short_term_memory.py b/ksadk/memory/adk/short_term_memory.py index 1bb1ea9b..9f5490a0 100644 --- a/ksadk/memory/adk/short_term_memory.py +++ b/ksadk/memory/adk/short_term_memory.py @@ -109,9 +109,7 @@ class ShortTermMemory(BaseModel): def model_post_init(self, __context: Any) -> None: # 优先使用 db_url if self.db_url: - logger.info( - f"ShortTermMemory: using db_url (ignoring backend option)" - ) + logger.info("ShortTermMemory: using db_url (ignoring backend option)") self._init_database_service(self.db_url) return @@ -133,7 +131,8 @@ def model_post_init(self, __context: Any) -> None: case "database": if not self.db_url: raise ValueError( - "KSADK_SESSION_DSN is required when ADK session backend resolves to database/postgres" + "KSADK_SESSION_DSN is required when ADK session backend " + "resolves to database/postgres" ) else: self._init_database_service(self.db_url) @@ -151,7 +150,20 @@ def _init_database_service(self, db_url: str) -> None: try: from google.adk.sessions import DatabaseSessionService - self._session_service = DatabaseSessionService(db_url=normalized_db_url) + from ksadk.memory.adk.resilient_session_service import ResilientADKSessionService + from ksadk.sessions.resilience import session_backend_timeout_seconds + + service_kwargs = {} + if normalized_db_url.startswith(("postgresql://", "postgresql+asyncpg://")): + timeout = session_backend_timeout_seconds() + service_kwargs["connect_args"] = { + "timeout": timeout, + "command_timeout": timeout, + } + + self._session_service = ResilientADKSessionService( + DatabaseSessionService(db_url=normalized_db_url, **service_kwargs) + ) logger.info( f"ShortTermMemory: using DatabaseSessionService " f"({normalized_db_url[:30]}...)" @@ -163,12 +175,14 @@ def _init_database_service(self, db_url: str) -> None: "Falling back to InMemorySessionService." ) self._session_service = InMemorySessionService() + self.backend = "local" except Exception as e: logger.error( f"Failed to create DatabaseSessionService: {e}. " f"Falling back to InMemorySessionService." ) self._session_service = InMemorySessionService() + self.backend = "local" @property def session_service(self) -> BaseSessionService: @@ -272,7 +286,8 @@ def from_env(cls) -> "ShortTermMemory": backend = explicit_backend if _session_backend_requires_database_url(backend) and not db_url: raise ValueError( - "KSADK_SESSION_DSN is required when ADK session backend resolves to database/postgres" + "KSADK_SESSION_DSN is required when ADK session backend " + "resolves to database/postgres" ) if not backend: if db_url: diff --git a/ksadk/runners/adk_runner.py b/ksadk/runners/adk_runner.py index aed95563..8e2a15bd 100644 --- a/ksadk/runners/adk_runner.py +++ b/ksadk/runners/adk_runner.py @@ -5,14 +5,19 @@ 支持通过环境变量配置记忆体 (ShortTermMemory / LongTermMemory)。 """ +import asyncio import base64 import inspect import logging import os import sys +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version from pathlib import Path from typing import Any, AsyncIterator, Dict, Mapping, Optional +from google.genai import types from opentelemetry import trace from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes @@ -44,6 +49,16 @@ def __init__(self, detection_result: Any, project_dir: str): self._knowledge_base = None # Keep runtime toolsets alive for the lifetime of the runner. self._runtime_toolsets: list[Any] = [] + # ADK resumability state + self._resumable: bool = False + self._resume_disabled_reason: Optional[str] = None + # 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._adk_resume_min_version = os.environ.get( + "GOOGLE_ADK_RESUME_MIN_VERSION", "1.16.0" + ).strip() async def close(self) -> None: """Close runtime toolsets owned by this runner.""" @@ -61,48 +76,155 @@ async def close(self) -> None: logger.warning("Failed to close runtime toolset %r: %s", toolset, exc) def _apply_json_patch(self): - """Monkey patch google.adk.models.lite_llm to handle invalid JSON safely""" + """Patch ADK LiteLlm to handle malformed JSON in tool-call arguments. + + The previous RobustJson approach replaced the entire json module inside + lite_llm.py, which broke ADK's streaming args-completeness detection: + ADK uses ``try: json.loads(args) except json.JSONDecodeError: pass`` + to decide whether accumulated streaming args form a complete JSON + object. When RobustJson.loads swallowed the exception (or even when + it re-raised after json_repair "fixed" incomplete fragments like + ``{`` into ``{}``), fallback_index incremented on every args fragment, + causing a single tool call to fragment into multiple entries with + empty names -- the "Tool '' not found" error. + + The new approach is surgical: we do NOT replace the json module at + all. Instead, we patch _message_to_generate_content_response to + tolerate malformed JSON in the FINAL assembled arguments only. + The streaming args detection continues using stdlib json.loads and + gets proper JSONDecodeError for incomplete fragments. + """ try: - import json + import inspect as _inspect + import json as _stdlib_json import google.adk.models.lite_llm as adk_lite_llm - # Create a proxy for the json module - class RobustJson: - def __getattr__(self, name): - return getattr(json, name) + _original_fn = adk_lite_llm._message_to_generate_content_response + if getattr(_original_fn, "__ksadk_json_patch__", False): + return + # 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() + ) - def loads(self, s, **kwargs): - result = {} - try: - result = json.loads(s, **kwargs) - except json.JSONDecodeError: - # Try json_repair if available - try: - import json_repair - - result = json_repair.loads(s) - except ImportError: - # Fallback: return empty dict to prevent crash - print( - f"\n⚠️ [KSADK] Warning: Captured invalid JSON from LLM: {s[:50]}..." - ) - result = {} - - # Ensure result is a dict (Google GenAI FunctionCall requires dict args) - if not isinstance(result, dict): - return {} - return result - - # Replace the 'json' module reference INSIDE lite_llm module - # This is safer than patching json.loads globally - adk_lite_llm.json = RobustJson() + def _patched_message_to_generate_content_response( + message, *, is_partial=False, model_version=None, thought_parts=None + ): + """Wrapper that catches JSONDecodeError in final args parsing.""" + # Only forward kwargs that the original function accepts, + # avoiding TypeError on older ADK versions that lack + # `model_version` or `thought_parts`. + forward_kwargs = {"is_partial": is_partial} + if "model_version" in _orig_params: + forward_kwargs["model_version"] = model_version + if "thought_parts" in _orig_params: + forward_kwargs["thought_parts"] = thought_parts + try: + result = _original_fn(message, **forward_kwargs) + except _stdlib_json.JSONDecodeError: + logger.warning( + "ADKRunner: caught JSONDecodeError in args parsing, " + "returning empty response" + ) + from google.adk.models import LlmResponse + from google.genai import types as _genai_types + + return LlmResponse( + content=_genai_types.Content(role="model", parts=[]), + partial=is_partial, + model_version=model_version, + ) + # If the response has function_call parts with empty args, + # fill in {} as fallback (this was what RobustJson used to do, + # but now only at the final output stage, not during streaming). + # Also strip phantom function_calls whose name is empty or + # whitespace-only — these are streaming artifacts produced by + # the old ADK fallback_index mechanism when parallel tool-call + # fragments are mis-assembled. + phantom_indices = set() + if result.content and result.content.parts: + 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() + ): + 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 + ] + return result + + _patched_message_to_generate_content_response.__ksadk_json_patch__ = True + adk_lite_llm._message_to_generate_content_response = ( + _patched_message_to_generate_content_response + ) + + # Don't patch _model_response_to_generate_content_response since + # it calls _message_to_generate_content_response internally, and + # that's already patched. Double-patching would cause recursion. + + logger.info( + "ADKRunner: Applied surgical args-parsing patch " + "(stdlib json preserved, streaming detection intact)" + ) except ImportError: pass # ADK not installed except Exception: pass + + def _apply_mcp_result_patch(self): + """Patch ADK McpTool to convert CallToolResult to dict. + + Old ADK (1.14.1) McpTool._run_async_impl returns the raw + CallToolResult Pydantic object from session.call_tool(), which + cannot be JSON-serialized when ADK builds the FunctionResponse. + New ADK (1.34.0) added response.model_dump(exclude_none=True, + mode="json") before returning. This patch replicates that fix + for the old version. + """ + try: + from google.adk.tools.mcp_tool.mcp_tool 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 + ): + response = await _original_run_async( + self, args=args, tool_context=tool_context, + credential=credential, + ) + # If response is a Pydantic model (e.g. CallToolResult), + # convert to dict so it can be JSON-serialized downstream. + if hasattr(response, "model_dump"): + return response.model_dump(exclude_none=True, mode="json") + return response + + _patched_run_async_impl.__ksadk_mcp_result_patch__ = True + McpTool._run_async_impl = _patched_run_async_impl + + logger.info( + "ADKRunner: Applied MCP result serialization patch " + "(CallToolResult -> dict via model_dump)" + ) + + except ImportError: + pass # ADK or MCP not installed + except Exception as exc: + logger.debug("ADKRunner: MCP result patch failed: %s", exc) + + def _init_short_term_memory(self): """从环境变量初始化短期记忆 @@ -144,6 +266,34 @@ def get_session_adapter(self): return ADKSessionAdapter() 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 + ) + if resumable: + backend = "adk_invocation" + if stm_backend == "sqlite": + backend = "adk_invocation+sqlite" + elif stm_backend == "database": + backend = "adk_invocation+postgres" + shared_across_pods = stm_backend == "database" + return { + "Supported": shared_across_pods, + "Backend": backend, + "Scope": "invocation", + "Durable": stm_backend is not None and stm_backend != "local", + "SharedAcrossPods": shared_across_pods, + "LocalOnly": not shared_across_pods, + "ResumeMode": "invocation_id", + "Reason": ( + "ADK ResumabilityConfig and shared database session backend enabled; " + "resume via invocation_id" + if shared_across_pods + else "ADK ResumabilityConfig enabled, but the session backend is " + "process-local or SQLite and cannot support cross-pod recovery" + ), + } return { "Supported": False, "Backend": "none", @@ -151,37 +301,204 @@ def describe_checkpoint_capability(self) -> dict[str, Any]: "Durable": False, "SharedAcrossPods": False, "ResumeMode": "forward_only", - "Reason": "ADK native session can continue conversation context, but KSADK does not expose ADK framework checkpoint restore points", + "Reason": ( + self._resume_disabled_reason + or "ADK ResumabilityConfig not enabled; set " + "KSADK_ADK_RESUMABLE=1 or configure App with resumability_config" + ), } def get_runtime_capabilities(self) -> dict[str, Any]: capabilities = super().get_runtime_capabilities() - has_native_session = bool(getattr(self, "_short_term_memory", None)) or any( - str(os.getenv(name) or "").strip() - for name in ( - "KSADK_ADK_SESSION_BACKEND", - "KSADK_ADK_SESSION_PATH", - "KSADK_ADK_SESSION_URL", - "KSADK_STM_BACKEND", - "KSADK_STM_PATH", - "KSADK_STM_URL", - "KSADK_STM_DB_PATH", - "KSADK_STM_DB_URL", - "KSADK_SESSION_BACKEND", - "KSADK_SESSION_DSN", - ) + resumable = getattr(self, "_resumable", False) + stm_backend = ( + getattr(self._short_term_memory, "backend", None) + if self._short_term_memory else None ) - capabilities["SessionContinuity"] = { - "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", - } + is_durable = stm_backend is not None and stm_backend != "local" + if resumable: + # P1.3: Level must degrade with backend — in_memory session state + # cannot survive pod restarts, so "runtime" is misleading. + level = "runtime" if is_durable else "semantic" + capabilities["SessionContinuity"] = { + "Supported": True, + "Type": "adk_invocation", + "Level": level, + "Reason": ( + "ADK ResumabilityConfig enabled with durable session backend, " + "invocation_id-based checkpoint resume available" + if is_durable + else "ADK ResumabilityConfig enabled but session state is in-memory; " + "resume only works within the same process lifetime" + ), + } + else: + has_native_session = bool(getattr(self, "_short_term_memory", None)) or any( + str(os.getenv(name) or "").strip() + for name in ( + "KSADK_ADK_SESSION_BACKEND", + "KSADK_ADK_SESSION_PATH", + "KSADK_ADK_SESSION_URL", + "KSADK_STM_BACKEND", + "KSADK_STM_PATH", + "KSADK_STM_URL", + "KSADK_STM_DB_PATH", + "KSADK_STM_DB_URL", + "KSADK_SESSION_BACKEND", + "KSADK_SESSION_DSN", + ) + ) + capabilities["SessionContinuity"] = { + "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", + } capabilities["ResumeRun"]["Reason"] = capabilities["Checkpoint"]["Reason"] return capabilities + @dataclass + class _ResolvabilityResult: + enabled: bool + 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 可恢复性。""" + # Priority 1: agent module exports app object with resumability_config + module = getattr(self, "_module", None) + if module is not None: + try: + from google.adk.apps 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) + except ImportError: + pass + + # Priority 2: environment variable explicit control + env_val = os.environ.get("KSADK_ADK_RESUMABLE", "").strip().lower() + if env_val in ("1", "true", "yes"): + return self._ResolvabilityResult(enabled=True, source="env", app=None) + + # Priority 3: persistent session backend auto-enable + stm_backend = ( + 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) + + return self._ResolvabilityResult(enabled=False, source="default", app=None) + + @staticmethod + def _get_adk_version() -> Optional[str]: + """返回当前安装的 google-adk 版本号,无法获取时返回 None。""" + try: + return _pkg_version("google-adk") + except PackageNotFoundError: + return None + + def _check_adk_resume_compatibility(self) -> tuple[bool, str]: + """检查当前 google-adk 版本是否支持恢复 (invocation_id)。 + + Returns: + (compatible, reason) — compatible=True 表示版本足够;reason 为不兼容时的说明文本。 + """ + adk_ver = self._get_adk_version() + if adk_ver is None: + # 无法获取版本信息,保守地认为不兼容 + 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, + f"google-adk {adk_ver} < {self._adk_resume_min_version}, " + f"run_async() does not accept invocation_id", + ) + except Exception: + # packaging 不可用时,保守地认为不兼容 + return False, "cannot compare google-adk version, assuming incompatible" + return True, "" + + def _build_runner(self) -> None: + """构造 ADK Runner,优先使用 App 对象以启用 ResumabilityConfig。""" + from google.adk.runners import Runner + + resumable = self._resolve_resumability() + resumability_enabled = resumable.enabled + + # 版本兼容性检查:低于最低版本时强制关闭恢复 + resume_compatible, resume_reason = self._check_adk_resume_compatibility() + if not resume_compatible and resumable.enabled: + 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 + + if resumable.app is not None: + runner_kwargs = dict( + app=resumable.app, + session_service=self._session_service, + ) + elif resumable.enabled: + try: + from google.adk.apps import App, ResumabilityConfig + app = App( + name=self._agent.name, + root_agent=self._agent, + resumability_config=ResumabilityConfig(is_resumable=True), + ) + runner_kwargs = dict( + app=app, + session_service=self._session_service, + ) + except ImportError: + logger.warning( + "ADK ResumabilityConfig not available (requires google-adk >= 1.14.0); " + "falling back to non-resumable Runner" + ) + runner_kwargs = dict( + agent=self._agent, + session_service=self._session_service, + app_name=self._agent.name, + ) + resumability_enabled = False + self._resume_disabled_reason = "ADK ResumabilityConfig is unavailable" + else: + runner_kwargs = dict( + agent=self._agent, + session_service=self._session_service, + app_name=self._agent.name, + ) + + if self._long_term_memory: + runner_kwargs["memory_service"] = self._long_term_memory + logger.info("ADKRunner: LongTermMemory injected as memory_service") + + self._runner = Runner(**runner_kwargs) + self._resumable = resumability_enabled + + if resumability_enabled: + stm_backend = ( + getattr(self._short_term_memory, "backend", None) + if self._short_term_memory else None + ) + logger.info( + "ADKRunner: resumability enabled (source=%s, backend=%s)", + resumable.source, + stm_backend or "in_memory", + ) + def _init_long_term_memory(self): """从环境变量初始化长期记忆 @@ -391,7 +708,9 @@ def _resolve_skills_mode(self) -> str: backend = (os.environ.get("KSADK_SANDBOX_BACKEND") or "").strip().lower() if backend and backend not in {"disabled", "none", "off"}: return "sandbox" - 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 "sandbox" skills_dir = Path( os.environ.get("KSADK_LOCAL_SKILLS_DIR") @@ -458,13 +777,18 @@ def _inject_builtin_tools(self): return added = self._append_tools_by_name(tools) if added: - logger.info("Injected ksadk built-in tools into agent (added: %s)", ", ".join(added)) + logger.info( + "Injected ksadk built-in tools into agent (added: %s)", + ", ".join(added), + ) else: logger.debug("ksadk built-in tools already present") except Exception as exc: logger.warning("Failed to inject ksadk built-in tools: %s", exc) - def inject_deferred_tools_for_request(self, tool_names: list[str] | tuple[str, ...]) -> list[str]: + def inject_deferred_tools_for_request( + self, tool_names: list[str] | tuple[str, ...] + ) -> list[str]: """Append direct built-in tools selected by deferred tool search.""" names = [str(name or "").strip() for name in tool_names or [] if str(name or "").strip()] if not names: @@ -475,7 +799,10 @@ def inject_deferred_tools_for_request(self, tool_names: list[str] | tuple[str, . tools = get_agentengine_tools(include=names, profile="coding", mode="direct") added = self._append_tools_by_name(tools) if added: - logger.info("Injected deferred ksadk tools for request (added: %s)", ", ".join(added)) + logger.info( + "Injected deferred ksadk tools for request (added: %s)", + ", ".join(added), + ) return added except Exception as exc: logger.warning("Failed to inject deferred ksadk tools for request: %s", exc) @@ -540,6 +867,7 @@ def load_agent(self) -> None: warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.main") self._apply_json_patch() + self._apply_mcp_result_patch() # 添加项目目录到 Python 路径 project_path = Path(self.project_dir).resolve() @@ -559,6 +887,7 @@ def load_agent(self) -> None: try: module = __import__(module_name, fromlist=[self.detection_result.agent_variable]) + self._module = module self._agent = getattr(module, self.detection_result.agent_variable) # Inject safety instruction for DeepSeek/LLMs to prevent empty tool names @@ -590,7 +919,6 @@ def load_agent(self) -> None: self._inject_search_knowledge_tool() # 初始化 SessionService - from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService if self._short_term_memory: @@ -608,17 +936,8 @@ def load_agent(self) -> None: self._inject_builtin_tools() self._inject_mcp_toolsets() - # 初始化 Runner (传入 memory_service) - runner_kwargs = dict( - agent=self._agent, - session_service=self._session_service, - app_name=self._agent.name, - ) - if self._long_term_memory: - runner_kwargs["memory_service"] = self._long_term_memory - logger.info("ADKRunner: LongTermMemory injected as memory_service") - - self._runner = Runner(**runner_kwargs) + # 初始化 Runner (使用 _build_runner 以支持 ResumabilityConfig) + self._build_runner() self._default_model_name = self.normalize_requested_model( os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME") ) @@ -730,7 +1049,8 @@ def prepare_for_request(self, model: str | None) -> None: return if self._agent is not None: self._apply_model_to_agent_tree(self._agent, normalized) - self._active_model_name = self._discover_model_reference(self._agent) or target_reference + discovered = self._discover_model_reference(self._agent) + self._active_model_name = discovered or target_reference return self._active_model_name = target_reference @@ -819,8 +1139,7 @@ def _build_adk_content( attachments: list[Dict[str, Any]], *, model_metadata: Dict[str, Any] | None = None, - ) -> "types.Content": - from google.genai import types + ) -> types.Content: parts = [] if text: parts.append(types.Part(text=text)) @@ -829,7 +1148,10 @@ def _build_adk_content( for att in attachments: mime_type = att.get("mime_type", "application/octet-stream") display_name = att.get("display_name", "") - if classify_attachment_kind(str(mime_type), str(display_name)) == "image" and not image_input_supported: + if ( + classify_attachment_kind(str(mime_type), str(display_name)) == "image" + and not image_input_supported + ): skipped_images.append(str(display_name or "未命名图片")) continue @@ -840,7 +1162,8 @@ def _build_adk_content( try: data = base64.b64decode(str(inline_data).strip() + "===") except Exception as e: - logger.warning(f"Failed to decode inline attachment {att.get('display_name', 'uploaded_file')}: {e}") + att_name = att.get("display_name", "uploaded_file") + logger.warning(f"Failed to decode inline attachment {att_name}: {e}") if data is None: file_uri = att.get("file_uri") @@ -853,7 +1176,10 @@ def _build_adk_content( file_uri = att.get("file_uri", "") if file_uri.startswith("local:"): logger.warning( - "Ignoring direct local attachment reference %s; only resolved storage paths are allowed.", + ( + "Ignoring direct local attachment reference %s; " + "only resolved storage paths are allowed." + ), file_uri, ) @@ -952,8 +1278,13 @@ def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]: input_tokens = int(usage_metadata.get("prompt_token_count") or 0) output_tokens = int(usage_metadata.get("candidates_token_count") or 0) - total_tokens = int(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): + total_tokens = int( + 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 + ): return {} return { "input_tokens": input_tokens, @@ -967,13 +1298,421 @@ 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( + self, + *, + session_id: str, + run_id: str, + ) -> int: + """扫描已有 run_checkpoint 事件,返回同一 run_id 下的最大 checkpoint_seq。 + + 恢复模式下 checkpoint_seq 从已有最大值继续递增,避免从 0 重启导致 + checkpoint_id 碰撞并被 append_run_checkpoint_event 的去重逻辑静默丢弃。 + """ + if not run_id: + return 0 + try: + from ksadk.sessions import resolve_session_service + + service = resolve_session_service() + events = await service.get_events(session_id) + max_seq = 0 + for event in reversed(events): + if event.event_type != "run_checkpoint": + continue + metadata = event.metadata or {} + if str(metadata.get("run_id") or "") != str(run_id): + continue + if str(metadata.get("framework") or "") != "adk": + continue + checkpoint_id = str(metadata.get("checkpoint_id") or "") + if checkpoint_id.startswith("adk-ckpt-"): + try: + seq = int(checkpoint_id.removeprefix("adk-ckpt-")) + if seq > max_seq: + max_seq = seq + except ValueError: + pass + return max_seq + except Exception as exc: + logger.warning( + "ADKRunner: failed to query max checkpoint_seq " + "for run_id=%s: %s", + run_id, exc, + ) + return 0 + + async def _collect_adk_invocation_id( + self, + events_async, + *, + ksadk_invocation_id: str, + session_id: str, + checkpoint_run_id: str = "", + ): + """包装 event 迭代器,在首个 event 到达时采集 ADK invocation_id 并存储映射, + 同时在可恢复边界写入 checkpoint 事件。 + + checkpoint_run_id 用于 checkpoint 的 run_id 字段,恢复模式下沿用原始 RunId + 以保证同一长任务的 checkpoint 时间线连贯;ksadk_invocation_id 用于 + invocation_id 映射和 event 级 invocation_id 字段。 + """ + effective_run_id = checkpoint_run_id or ksadk_invocation_id + first_event_captured = False + 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, + ) + async for event in events_async: + if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id: + first_event_captured = True + # P1.1: Use local variable instead of self._last_adk_invocation_id + # to avoid cross-session corruption under concurrent runner access. + captured_adk_invocation_id = event.invocation_id + await self._persist_invocation_mapping( + session_id=session_id, + ksadk_invocation_id=ksadk_invocation_id, + adk_invocation_id=captured_adk_invocation_id, + ) + # 每个 resumable boundary 都立即写 checkpoint,用递增 seq 保证 + # 即使程序崩溃也有最新恢复点。 + if self._resumable and first_event_captured and self._is_resumable_boundary(event): + checkpoint_seq += 1 + await self._maybe_write_checkpoint( + event=event, + session_id=session_id, + ksadk_invocation_id=ksadk_invocation_id, + adk_invocation_id=captured_adk_invocation_id, + checkpoint_seq=checkpoint_seq, + checkpoint_run_id=effective_run_id, + ) + yield event + + async def _collect_adk_invocation_id_if_present( + self, + events_async, + *, + session_id: str, + ksadk_invocation_id: str, + ): + """轻量版 invocation_id 采集:仅捕获 ID 并持久化映射,不写 checkpoint。""" + first_event_captured = False + async for event in events_async: + if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id: + first_event_captured = True + # P1.1: Local variable — self._last_adk_invocation_id was removed + # to prevent cross-session corruption under concurrent runner access. + local_adk_invocation_id = event.invocation_id + if ksadk_invocation_id: + await self._persist_invocation_mapping( + session_id=session_id, + ksadk_invocation_id=ksadk_invocation_id, + adk_invocation_id=local_adk_invocation_id, + ) + yield event + + async def _persist_invocation_mapping( + self, + *, + session_id: str, + ksadk_invocation_id: str, + adk_invocation_id: str, + ) -> None: + """将 ksadk invocation_id → ADK invocation_id 映射持久化到 session binding state。 + + 存储位置:ksadk_states 表,scope = "runner_binding:adk" + state_json 内新增字段 invocation_map: { ksadk_inv_id: adk_inv_id, ... } + 无需新增数据库表或字段,复用现有 state_json 的 JSON 存储。 + """ + try: + from ksadk.sessions import resolve_session_service + from ksadk.sessions.continuity import ConversationSessionCore + + service = resolve_session_service() + core = ConversationSessionCore(service) + # P1.1 sub-issue: hold the lock across the entire read-modify-write + # so concurrent invocations on the same session can't lose mappings. + async with self._invocation_map_lock: + binding = await core.get_binding_by_session_id(session_id, "adk") + invocation_map = dict(binding.get("invocation_map") or {}) + invocation_map[ksadk_invocation_id] = adk_invocation_id + + await core.set_binding_by_session_id( + session_id, + "adk", + { + "external_session_id": str(session_id), + "internal_session_id": str(session_id), + "invocation_map": invocation_map, + }, + ) + except Exception as exc: + logger.warning("Failed to persist ADK invocation mapping: %s", exc) + + async def _resolve_adk_invocation_id( + self, + *, + session_id: str, + ksadk_invocation_id: str, + ) -> str | None: + """从 session binding state 读取 ADK invocation_id 映射。""" + try: + from ksadk.sessions import resolve_session_service + from ksadk.sessions.continuity import ConversationSessionCore + + service = resolve_session_service() + core = ConversationSessionCore(service) + binding = await core.get_binding_by_session_id(session_id, "adk") + invocation_map = dict(binding.get("invocation_map") or {}) + return invocation_map.get(ksadk_invocation_id) + except Exception: + return None + + + def _is_resumable_boundary(self, event: Any) -> bool: + """判断 ADK event 是否为可恢复边界。""" + # 工具调用请求 + if hasattr(event, "get_function_calls") and event.get_function_calls(): + return True + # 自定义 Agent 状态保存 + if hasattr(event, "actions") and event.actions: + if getattr(event.actions, "agent_state", None) is not None: + return True + if getattr(event.actions, "end_of_agent", False): + return True + return False + + async def _maybe_write_checkpoint( + self, + *, + event: Any, + session_id: str, + ksadk_invocation_id: str, + adk_invocation_id: str, + checkpoint_seq: int, + checkpoint_run_id: str = "", + ) -> None: + """Write a run_checkpoint event at a resumable boundary. + + Each boundary gets its own incrementing checkpoint_id so the latest + checkpoint always reflects the latest state for crash recovery. + """ + if not self._is_resumable_boundary(event): + return + + from ksadk.conversations.runtime import append_run_checkpoint_event + + metadata = self._extract_checkpoint_metadata(event) + + effective_run_id = checkpoint_run_id or ksadk_invocation_id + await append_run_checkpoint_event( + session_id=session_id, + author=self._agent.name, + run_id=effective_run_id, + checkpoint_id=f"adk-ckpt-{checkpoint_seq}", + framework="adk", + framework_ref={ + "adk": { + "invocation_id": adk_invocation_id, + "checkpoint_seq": checkpoint_seq, + "event_id": getattr(event, "id", ""), + "author": getattr(event, "author", ""), + } + }, + phase=( + "tool_call" + if (hasattr(event, "get_function_calls") and event.get_function_calls()) + else "agent_state" + ), + invocation_id=ksadk_invocation_id, + metadata=metadata, + ) + logger.debug( + "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]: + """从 ADK event 中提取 checkpoint 元数据。""" + metadata: dict[str, Any] = {} + + # 工具调用信息 + if hasattr(event, "get_function_calls") and event.get_function_calls(): + fcs = event.get_function_calls() + metadata["tool_names"] = [fc.name for fc in fcs if hasattr(fc, "name")] + metadata["tool_call_ids"] = [fc.id for fc in fcs if hasattr(fc, "id")] + + # Agent 状态信息 + if hasattr(event, "actions") and event.actions: + agent_state = getattr(event.actions, "agent_state", None) + if agent_state is not None: + if isinstance(agent_state, dict): + metadata["agent_state_keys"] = list(agent_state.keys()) + else: + metadata["agent_state_keys"] = [] + if getattr(event.actions, "end_of_agent", False): + metadata["is_terminal"] = True + metadata["end_of_agent"] = True + + # 是否可恢复 + stm_backend = ( + 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 + metadata["is_resumable"] = platform_resumable + metadata["resume_status"] = "resumable" if platform_resumable else "disabled" + metadata["backend"] = stm_backend or "in_memory" + metadata["scope"] = "invocation" + metadata["durable"] = stm_backend is not None and stm_backend != "local" + metadata["shared_across_pods"] = shared_across_pods + if not platform_resumable: + metadata["resume_disabled_reason"] = ( + self._resume_disabled_reason + if not self._resumable and self._resume_disabled_reason + else "ADK checkpoint uses an in-memory or local-only session backend; " + "cross-pod resume is unavailable" + ) + # P1.4: ADK only supports invocation-level (forward-only) resume, not + # arbitrary checkpoint rollback like LangGraph time_travel. Consumers + # should treat only the latest checkpoint as independently resumable. + metadata["resume_mode"] = "invocation_id" + metadata["only_latest_resumable"] = True + + return metadata + + async def _resolve_resume_invocation_id( + self, + *, + input_data: Dict[str, Any], + session_id: str, + ksadk_invocation_id: str, + ) -> str: + """Resolve the ADK invocation_id needed for resume. + + Looks up from framework_ref or session binding mapping. + Raises ValueError when the resume reference is missing (P1.2: + never silently downgrade to a new invocation). + """ + adk_invocation_id = None + framework_ref = input_data.get("framework_ref") or {} + if isinstance(framework_ref, dict): + adk_ref = framework_ref.get("adk") or {} + if isinstance(adk_ref, dict): + adk_invocation_id = adk_ref.get("invocation_id") + # Fallback: resolve from session binding + if not adk_invocation_id and ksadk_invocation_id: + adk_invocation_id = await self._resolve_adk_invocation_id( + session_id=session_id, + ksadk_invocation_id=ksadk_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, + ) + raise ValueError( + f"checkpoint_not_resumable: ADK invocation_id not found for " + f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}. " + f"The checkpoint data may have been lost or the invocation was " + f"never persisted." + ) + return adk_invocation_id + + async def _prepare_run_events( + self, + *, + input_data: Dict[str, Any], + session_id: str, + user_input: str, + is_resume: bool, + run_config: Optional[Any] = None, + ) -> AsyncIterator[Any]: + """Prepare the wrapped event stream shared by invoke() and stream(). + + Handles new_message construction, resume invocation_id resolution, + run_async (with optional run_config for streaming), and event wrapping + for checkpoint writing. Returns the wrapped async iterator. + """ + if not is_resume: + new_message = self._build_adk_content( + user_input, + input_data.get("attachments", []), + model_metadata=input_data.get("model_metadata"), + ) + else: + new_message = None + + 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() + if is_resume and str(input_data.get("run_id") or "").strip() + else "" + ) + + run_kwargs: Dict[str, Any] = { + "session_id": session_id, + "user_id": "ksadk_user", + } + if run_config is not None: + run_kwargs["run_config"] = run_config + + if is_resume: + if not self._resumable: + raise ValueError( + "checkpoint_not_resumable: ADK resumability is disabled for " + f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}." + ) + adk_invocation_id = await self._resolve_resume_invocation_id( + input_data=input_data, + session_id=session_id, + ksadk_invocation_id=ksadk_invocation_id, + ) + logger.info("Resuming ADK run with adk_invocation_id: %s", adk_invocation_id) + run_kwargs["invocation_id"] = adk_invocation_id + else: + run_kwargs["new_message"] = new_message + run_kwargs["state_delta"] = self._build_state_delta(input_data) or None + + events_async = self._runner.run_async(**run_kwargs) + + if ksadk_invocation_id and self._resumable: + wrapped_async = self._collect_adk_invocation_id( + events_async, + ksadk_invocation_id=ksadk_invocation_id, + session_id=session_id, + checkpoint_run_id=checkpoint_run_id, + ) + else: + wrapped_async = self._collect_adk_invocation_id_if_present( + events_async, + session_id=session_id, + ksadk_invocation_id=ksadk_invocation_id, + ) + return wrapped_async + async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 ADK Agent""" - from google.genai import types - user_input = input_data.get("input", "") + # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理 + is_resume = bool(input_data.get("checkpoint_resume")) + raw_input = input_data.get("input") + if is_resume and isinstance(raw_input, dict): + user_input = "[checkpoint resume]" + else: + user_input = str(raw_input or "") instructions = str(input_data.get("instructions") or "").strip() - if instructions: + if instructions and not is_resume: user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}" # 1. 准备 Metadata (提前以此获取 Agent Name) @@ -983,7 +1722,12 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: with tracer.start_as_current_span(trace_name) as span: # Set input.value for Langfuse top-level input display span.set_attribute("input.value", user_input) - span.set_attribute("user.input", user_input[:200]) + truncated_input = ( + user_input[:200] + if isinstance(user_input, str) + else str(user_input)[:200] + ) + span.set_attribute("user.input", truncated_input) # Use external session ID if provided req_session_id = input_data.get("session_id") @@ -999,25 +1743,24 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: if tags: span.set_attribute("langfuse.tags", ",".join(tags)) - new_message = self._build_adk_content( - user_input, - input_data.get("attachments", []), - model_metadata=input_data.get("model_metadata"), + wrapped_async = await self._prepare_run_events( + input_data=input_data, + session_id=session_id, + user_input=user_input, + is_resume=is_resume, ) - state_delta = self._build_state_delta(input_data) final_response = "" events_list = [] + # P1.4: Track last_event to avoid UnboundLocalError when ADK returns + # zero events (e.g. resuming a completed invocation). + last_event = None usage: dict[str, Any] = {} last_usage: dict[str, Any] = {} - async for event in self._runner.run_async( - session_id=session_id, - user_id="ksadk_user", - new_message=new_message, - state_delta=state_delta or None, - ): + async for event in wrapped_async: events_list.append(event) + last_event = event event_usage = self._extract_event_usage(event) if event_usage: usage = accumulate_usage(usage, event_usage) @@ -1028,7 +1771,25 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: # 过滤掉思考内容 (thought=True),只保留最终答案 is_thought = getattr(part, "thought", False) if hasattr(part, "text") and part.text and not is_thought: - final_response = part.text + final_response += part.text + + # Prefer the last event's text (usually the complete answer). + # When the loop aborts early (MCP error, phantom tool-call), + # 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 []): + is_thought = getattr(part, "thought", False) + if hasattr(part, "text") and part.text and not is_thought: + last_event_text += part.text + if last_event_text: + final_response = last_event_text + + if not final_response and events_list: + logger.warning( + "ADK invoke finished with events but no final text — " + "likely mid-loop abort or tool-call error" + ) # Set output.value for Langfuse top-level output display span.set_attribute("output.value", final_response[:5000] if final_response else "") @@ -1046,11 +1807,16 @@ 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 google.genai import types - user_input = input_data.get("input", "") + # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理 + is_resume = bool(input_data.get("checkpoint_resume")) + raw_input = input_data.get("input") + if is_resume and isinstance(raw_input, dict): + user_input = "[checkpoint resume]" + else: + user_input = str(raw_input or "") instructions = str(input_data.get("instructions") or "").strip() - if instructions: + if instructions and not is_resume: user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}" # 1. 准备 Metadata (提前以此获取 Agent Name) @@ -1060,7 +1826,12 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An with tracer.start_as_current_span(trace_name) as span: # Set input.value for Langfuse top-level input display span.set_attribute("input.value", user_input) - span.set_attribute("user.input", user_input[:200]) + truncated_input = ( + user_input[:200] + if isinstance(user_input, str) + else str(user_input)[:200] + ) + span.set_attribute("user.input", truncated_input) # Use external session ID if provided req_session_id = input_data.get("session_id") @@ -1075,27 +1846,20 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if tags: span.set_attribute("langfuse.tags", ",".join(tags)) - new_message = self._build_adk_content( - user_input, - input_data.get("attachments", []), - model_metadata=input_data.get("model_metadata"), + run_config = RunConfig(streaming_mode=StreamingMode.SSE) + wrapped_async = await self._prepare_run_events( + input_data=input_data, + session_id=session_id, + user_input=user_input, + is_resume=is_resume, + run_config=run_config, ) - state_delta = self._build_state_delta(input_data) accumulated_text = "" usage: dict[str, Any] = {} last_usage: dict[str, Any] = {} - # 使用 StreamingMode.SSE 启用真正的流式输出 - run_config = RunConfig(streaming_mode=StreamingMode.SSE) - - async for event in self._runner.run_async( - session_id=session_id, - user_id="ksadk_user", - new_message=new_message, - state_delta=state_delta or None, - run_config=run_config, - ): + async for event in wrapped_async: event_usage = self._extract_event_usage(event) if event_usage: usage = accumulate_usage(usage, event_usage) @@ -1113,17 +1877,71 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An "type": "thinking" if is_thought else "text", } - # 处理工具调用事件 + # 处理工具调用事件 — ADK 通过 event.content.parts[].function_call + # 发出工具调用(即 event.get_function_calls()),而非 + # event.actions.tool_calls。此处需同时检测两种路径: + # (a) get_function_calls() — ADK 标准 Event 模型 + # (b) actions.tool_calls — 某些旧版或自定义 runner 可能使用 + emitted_tool_call_ids: set[str] = set() + if hasattr(event, "get_function_calls") and event.get_function_calls(): + for fc in event.get_function_calls(): + fc_id = getattr(fc, "id", "") or getattr(fc, "name", "unknown") + if fc_id in emitted_tool_call_ids: + continue + emitted_tool_call_ids.add(fc_id) + # fc.args 可能是 dict 或 None;确保可序列化 + fc_args = getattr(fc, "args", None) or {} + 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 = {} + yield { + "type": "tool_call", + "tool_name": getattr(fc, "name", "unknown"), + "tool_args": fc_args, + } if hasattr(event, "actions") and event.actions: tool_calls = getattr(event.actions, "tool_calls", None) if tool_calls: for tool_call in tool_calls: + tc_name = getattr(tool_call, "name", "unknown") + tc_id = getattr(tool_call, "id", "") or tc_name + if tc_id in emitted_tool_call_ids: + continue + emitted_tool_call_ids.add(tc_id) yield { "type": "tool_call", - "tool_name": getattr(tool_call, "name", "unknown"), + "tool_name": tc_name, "tool_args": getattr(tool_call, "input", {}), } + # 处理工具返回结果 — ADK 通过 event.content.parts[].function_response + # 发出工具执行结果。当工具执行完毕后 ADK 会产生一个包含 + # function_response 的事件,此处将其转为 tool_result 语义事件, + # 让前端可以感知"某个工具已完成执行"并展示结果。 + if hasattr(event, "content") and event.content and hasattr(event.content, "parts"): + for part in event.content.parts: + fr = getattr(part, "function_response", None) + if fr is not None: + fr_name = getattr(fr, "name", "unknown") + fr_output = getattr(fr, "response", None) or {} + if not isinstance(fr_output, dict): + try: + import json as _json2 + if isinstance(fr_output, str): + fr_output = _json2.loads(fr_output) + else: + fr_output = {"raw": str(fr_output)} + except Exception: + fr_output = {"raw": str(fr_output)} + yield { + "type": "tool_result", + "tool_name": fr_name, + "tool_output": fr_output, + } + # Set output.value for Langfuse top-level output display span.set_attribute("output.value", accumulated_text[:5000] if accumulated_text else "") span.set_attribute("agent.output", accumulated_text[:500]) diff --git a/ksadk/runners/langchain_runner.py b/ksadk/runners/langchain_runner.py index d983033e..2740716a 100644 --- a/ksadk/runners/langchain_runner.py +++ b/ksadk/runners/langchain_runner.py @@ -110,9 +110,66 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An accumulated_text = "" last_chunk: Any = None + final_output_text = "" + emitted_non_text_event = False + message_snapshots: dict[str, str] = {} try: - if hasattr(self._agent, "astream"): + 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, @@ -120,7 +177,15 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An ) async for chunk in self._agent.astream(payload, **kwargs): last_chunk = chunk - delta, chunk_type = self._extract_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} @@ -132,14 +197,32 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An ) for chunk in self._agent.stream(payload, **kwargs): last_chunk = chunk - delta, chunk_type = self._extract_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: - print(f"\n⚠️ 流式调用失败: {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) @@ -160,6 +243,12 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An 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)): @@ -168,7 +257,9 @@ def _resolve_request_path(self) -> str: return "runnable_with_message_history" return "replay" - def _prepare_with_standard_hook(self, input_data: Dict[str, Any], session_id: str) -> dict[str, Any]: + 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): @@ -195,7 +286,11 @@ def _prepare_with_standard_hook(self, input_data: Dict[str, Any], session_id: st @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 + 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) @@ -231,7 +326,11 @@ def _prepare_with_replay(self, input_data: Dict[str, Any]) -> dict[str, Any]: 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 "" + 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}") @@ -518,6 +617,92 @@ def _extract_output(result: Any) -> str: return str(content) return str(result) + @classmethod + def _extract_recognized_output(cls, result: Any) -> str: + if isinstance(result, dict) and not any( + key in result for key in ("output", "text", "messages") + ): + return "" + if result is None: + return "" + return cls._extract_output(result) + + @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: diff --git a/ksadk/runners/langgraph_runner.py b/ksadk/runners/langgraph_runner.py index 7362b5d2..65a11059 100644 --- a/ksadk/runners/langgraph_runner.py +++ b/ksadk/runners/langgraph_runner.py @@ -12,6 +12,7 @@ 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 @@ -479,6 +480,51 @@ async def _invoke_graph( ) return self._agent.invoke(payload, **kwargs) + async def _invoke_from_stream_events(self, input_data: Dict[str, Any]) -> Dict[str, Any]: + chunks: list[dict[str, Any]] = [] + accumulated_text = "" + final_chunk: dict[str, Any] | None = None + metadata: dict[str, Any] = {} + + async for chunk in self.stream(input_data): + chunks.append(dict(chunk)) + chunk_type = chunk.get("type") + if chunk_type == "text": + accumulated_text += str(chunk.get("delta") or "") + elif chunk_type == "final": + final_chunk = dict(chunk) + elif chunk_type == "checkpoint": + chunk_metadata = chunk.get("metadata") + if isinstance(chunk_metadata, Mapping): + metadata.update(dict(chunk_metadata)) + elif chunk_type == "interrupt": + return { + "type": "interrupt", + "interrupt_info": chunk.get("interrupt_info"), + "session_id": chunk.get("session_id") or input_data.get("session_id"), + "output": ( + chunk.get("interrupt_info", {}).get("message", "需要用户确认") + if isinstance(chunk.get("interrupt_info"), Mapping) + else "需要用户确认" + ), + "raw": {"chunks": chunks}, + } + + if final_chunk: + output_text = str(final_chunk.get("output") or accumulated_text) + final_metadata = final_chunk.get("metadata") + if isinstance(final_metadata, Mapping): + metadata = {**dict(final_metadata), **metadata} + result: dict[str, Any] = {"output": output_text, "raw": {"chunks": chunks}} + usage = final_chunk.get("usage") + if isinstance(usage, Mapping) and usage: + result["usage"] = dict(usage) + if metadata: + result["metadata"] = metadata + return result + + return {"output": accumulated_text, "raw": {"chunks": chunks}} + async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: """调用 LangGraph 图 @@ -487,6 +533,10 @@ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]: 2. 原生格式: {"messages": [...]} 或自定义 State - 直接透传 """ payload = dict(input_data) + force_graph_invoke = bool(payload.pop("_ksadk_force_graph_invoke", False)) + if not force_graph_invoke and hasattr(self._agent, "astream_events"): + return await self._invoke_from_stream_events(payload) + 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)) @@ -646,6 +696,7 @@ async def _stream_checkpoint_resume_updates( async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]: """流式调用 LangGraph 图""" payload = dict(input_data) + payload.pop("_ksadk_force_graph_invoke", None) session_id = payload.pop("session_id", None) or str(uuid.uuid4())[:8] history = payload.pop("history", []) is_resume = payload.pop("resume", False) @@ -689,6 +740,51 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An emitted_non_text_event = False final_output_text = "" final_output_usage: dict[str, Any] = {} + final_output_last_usage: dict[str, Any] = {} + model_run_usages: dict[str, dict[str, Any]] = {} + model_run_order: list[str] = [] + stream_usage_run_keys: set[str] = set() + latest_stream_usage: dict[str, Any] = {} + + def model_run_key( + event: Mapping[str, Any], + *, + fallback_key: str | None = None, + ) -> str: + raw_run_id = event.get("run_id") + return ( + str(raw_run_id) + if raw_run_id + else fallback_key or f"model-event-{len(model_run_order)}" + ) + + def record_model_usage( + event: Mapping[str, Any], + usage: dict[str, Any], + *, + fallback_key: str | None = None, + ) -> None: + if not usage: + return + run_key = model_run_key(event, fallback_key=fallback_key) + if run_key not in model_run_usages: + model_run_order.append(run_key) + model_run_usages[run_key] = dict(usage) + + def accumulated_model_usage() -> dict[str, Any]: + if len(model_run_order) == 1: + return dict(model_run_usages.get(model_run_order[0]) or {}) + usage: dict[str, Any] = {} + for run_key in model_run_order: + usage = accumulate_usage(usage, model_run_usages.get(run_key) or {}) + return usage + + def latest_model_usage() -> dict[str, Any]: + for run_key in reversed(model_run_order): + usage = model_run_usages.get(run_key) + if usage: + return dict(usage) + return {} if is_checkpoint_resume and callable(getattr(self._agent, "astream", None)): try: @@ -731,6 +827,19 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An chunk = event.get("data", {}).get("chunk") if not chunk: continue + chunk_usage = self._extract_usage(chunk) + if chunk_usage: + # Some LangChain providers attach cumulative usage to + # every stream chunk, and LangChain may then sum those + # cumulative snapshots into an inflated + # on_chat_model_end usage. For a concrete model run, + # keep the latest stream snapshot and ignore the later + # end usage for that same run_id. + latest_stream_usage = dict(chunk_usage) + if event.get("run_id"): + run_key = model_run_key(event) + stream_usage_run_keys.add(run_key) + record_model_usage(event, latest_stream_usage) # 推理内容 reasoning = getattr(chunk, "reasoning_content", None) @@ -760,6 +869,15 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An accumulated_text += part.text yield {"delta": part.text, "type": "text"} + elif event_kind == "on_chat_model_end": + data = event.get("data") or {} + output = data.get("output") if isinstance(data, Mapping) else None + usage = self._extract_usage(output) or self._extract_usage(data) + last_usage = self._extract_last_usage(output) or self._extract_last_usage(data) + run_key = model_run_key(event) + if run_key not in stream_usage_run_keys: + record_model_usage(event, last_usage or usage) + elif event_kind == "on_tool_start": emitted_non_text_event = True yield { @@ -811,13 +929,17 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if not accumulated_text: if final_output_text: final_chunk = {"output": final_output_text, "type": "final"} - if final_output_usage: - final_chunk["usage"] = final_output_usage - if final_output_last_usage: - final_chunk.setdefault("metadata", {})["last_usage"] = final_output_last_usage + 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 + 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) + result = await self.invoke( + {**invoke_payload, "_ksadk_force_graph_invoke": True} + ) final_chunk = {"output": result.get("output", ""), "type": "final"} usage = self._extract_usage(result) if usage: @@ -832,11 +954,12 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An return else: final_chunk = {"output": accumulated_text, "type": "final"} - usage = await self._latest_state_usage(config) + state_usage = await self._latest_state_usage(config) + usage = accumulated_model_usage() or state_usage or final_output_usage or latest_stream_usage if usage: final_chunk["usage"] = usage - # _latest_state_usage 返回末个 message usage,单次调用场景即 last_usage - final_chunk.setdefault("metadata", {})["last_usage"] = 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 metadata = await self._latest_checkpoint_metadata(config) diff --git a/ksadk/runners/remote_runner.py b/ksadk/runners/remote_runner.py index 708f311b..01e9d0e8 100644 --- a/ksadk/runners/remote_runner.py +++ b/ksadk/runners/remote_runner.py @@ -1,7 +1,7 @@ """ RemoteRunner - 远程 Agent 运行时 -与 AgentTUI 配合使用,提供和本地 Runner 一致的接口 +与 InteractionLoop 配合使用,提供和本地 Runner 一致的接口 """ import json @@ -43,6 +43,11 @@ def __init__( self._agent = None # 兼容 BaseRunner self._responses_tool_names: dict[str, str] = {} self._responses_tool_args: dict[str, str] = {} + self._responses_streamed_item_keys: set[str] = set() + 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 拿到的可选模型列表 @staticmethod def _normalize_api_format(api_format: Optional[str]) -> str: @@ -362,6 +367,14 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An """流式调用远程 Agent""" import httpx + # 工具 item/call_id 只在单次 response 内有效。RemoteRunner 会被 TUI 多轮复用, + # 若保留上一轮的追踪状态,runtime 复用 id 时 response.completed 会被误判为重放。 + self._responses_tool_names.clear() + self._responses_tool_args.clear() + self._responses_streamed_item_keys.clear() + self._responses_text_streamed = False + self._responses_reasoning_streamed = False + user_input = input_data.get("input", "") session_id = input_data.get("session_id") or self.session_id @@ -406,6 +419,17 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An try: data = json.loads(data_str) + # 记录回包里的真实模型名: + # chat completions 顶层 data.model;responses 格式在 + # data.response.model(response.completed 事件的 response 对象)。 + observed = data.get("model") if isinstance(data, Mapping) else None + if not observed and isinstance(data, Mapping): + resp = data.get("response") + if isinstance(resp, Mapping): + observed = resp.get("model") + if observed and not isinstance(observed, (list, dict)): + self._observed_model = str(observed) + if self.api_format == "responses": async for item in self._iter_responses_stream_events( data, event_name=event_name @@ -413,11 +437,26 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An yield item continue + # 兜底: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 "") + if _ev.startswith("response."): + async for item in self._iter_responses_stream_events( + data, event_name=event_name + ): + yield item + continue + # 解析 OpenAI Chat Completions 流式格式 choices = data.get("choices", []) usage = data.get("usage") if isinstance(usage, Mapping): - yield {"type": "final", "usage": dict(usage)} + final_chunk = {"type": "final", "usage": dict(usage)} + metadata = data.get("metadata") + if isinstance(metadata, Mapping): + final_chunk["metadata"] = dict(metadata) + yield final_chunk final_sent = True if choices: delta = choices[0].get("delta", {}) @@ -429,6 +468,13 @@ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, An if content: accumulated_text += content yield {"delta": content, "type": "text"} + else: + # 非标准简化流(runtime 直发顶层 delta,无 choices 包装): + # 兜底提取顶层 delta 作为正文,与 _extract_content 口径一致。 + top_delta = data.get("delta") + if isinstance(top_delta, str) and top_delta: + accumulated_text += top_delta + yield {"delta": top_delta, "type": "text"} except json.JSONDecodeError: pass @@ -484,11 +530,14 @@ def _responses_item_key(item: Dict[str, Any], data: Dict[str, Any]) -> str: def _remember_responses_tool( self, key: str, item: Dict[str, Any], name: str, args: str ) -> None: + if key: + self._responses_streamed_item_keys.add(key) if key: self._responses_tool_names[key] = name self._responses_tool_args[key] = args call_id = str(item.get("call_id") or "") if call_id: + self._responses_streamed_item_keys.add(call_id) self._responses_tool_names[call_id] = name self._responses_tool_args[call_id] = args @@ -526,6 +575,8 @@ async def _iter_responses_output_item( data: Dict[str, Any], *, status: str, + replay_completed_text: bool = False, + replay_completed_reasoning: bool = False, ) -> AsyncIterator[Dict[str, Any]]: item = data.get("item") or data.get("output_item") or data if not isinstance(item, dict): @@ -541,15 +592,20 @@ 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} + yield {"type": "tool_call", "tool_name": name, "tool_args": args, "status": status, "call_id": key} return if item_type == "function_call_output": name = self._responses_tool_name(key, item) + if key: + self._responses_streamed_item_keys.add(key) + call_id = str(item.get("call_id") or "") + if call_id: + self._responses_streamed_item_keys.add(call_id) 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} + yield {"type": "tool_result", "tool_name": name, "tool_output": output, "call_id": call_id or key} return if item_type == "mcp_approval_request": @@ -576,13 +632,15 @@ async def _iter_responses_output_item( if item_type in {"reasoning", "reasoning_summary", "reasoning_summary_text"}: text = self._responses_item_text(item) - if text: + if text and (status != "completed" or replay_completed_reasoning): + self._responses_reasoning_streamed = True yield {"delta": text, "type": "thinking"} return if item_type == "message": text = self._responses_item_text(item) - if text and status != "completed": + if text and (status != "completed" or replay_completed_text): + self._responses_text_streamed = True yield {"delta": text, "type": "text"} return @@ -596,6 +654,7 @@ async def _iter_responses_stream_events( if event_name == "response.reasoning.delta": delta = data.get("delta") if delta: + self._responses_reasoning_streamed = True yield {"delta": str(delta), "type": "thinking"} return if event_type in { @@ -606,12 +665,16 @@ async def _iter_responses_stream_events( }: delta = data.get("delta") or data.get("text") if delta: + self._responses_reasoning_streamed = True yield {"delta": str(delta), "type": "thinking"} return - if event_type == "response.output_text.delta": - delta = data.get("delta") - if delta: - yield {"delta": str(delta), "type": "text"} + if event_type in {"response.output_text.delta", "response.output_text.done"}: + text = data.get("delta") or data.get("text") + if text and ( + event_type == "response.output_text.delta" or not self._responses_text_streamed + ): + self._responses_text_streamed = True + yield {"delta": str(text), "type": "text"} return if event_type == "response.output_item.added": async for item in self._iter_responses_output_item(data, status="running"): @@ -621,13 +684,35 @@ async def _iter_responses_stream_events( async for item in self._iter_responses_output_item(data, status="completed"): yield item return + # 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")) + 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} + return + if event_type in { + "response.tool_result", + "response.ksadk.tool_result", + "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")) + cid = str(data.get("call_id") or data.get("item_id") or data.get("run_id") or "") + yield { + "type": "tool_result", + "tool_name": name, + "tool_output": output, + "call_id": cid, + } + return if event_type == "response.function_call_arguments.delta": key = str(data.get("item_id") or data.get("call_id") or "") name = self._responses_tool_name(key, data) 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"} + 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 "") @@ -637,12 +722,30 @@ 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"} + 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): + 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: + if isinstance(item, dict): + key = self._responses_item_key(item, {"item": item}) + call_id = str(item.get("call_id") or "") + if (key and key in replayed_item_keys) or ( + call_id and call_id in replayed_item_keys + ): + continue + async for projected in self._iter_responses_output_item( + {"item": item}, + status="completed", + replay_completed_text=replay_completed_text, + replay_completed_reasoning=replay_completed_reasoning, + ): + yield projected chunk = { "type": "responses_output", "output": output, @@ -651,17 +754,39 @@ async def _iter_responses_stream_events( usage = response.get("usage") if isinstance(usage, Mapping): chunk["usage"] = dict(usage) + metadata = response.get("metadata") + if isinstance(metadata, Mapping): + chunk["metadata"] = dict(metadata) yield chunk return + # 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 {} + 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 "") + text = "" + if isinstance(delta, dict): + text = str(delta.get("text") or delta.get("content") or "") + elif isinstance(delta, str): + text = delta + if not text: + text = str(data.get("text") or "") + if not text: + return + if "reasoning" in part_type: + self._responses_reasoning_streamed = True + yield {"delta": text, "type": "thinking"} + else: + self._responses_text_streamed = True + yield {"delta": text, "type": "text"} + return if event_type == "response.failed": yield {"type": "error", "message": self._responses_error_message(data)} return if event_type == "response.incomplete": yield {"type": "error", "message": "Agent 响应未完成"} return - if isinstance(data.get("delta"), str): - yield {"delta": str(data["delta"]), "type": "text"} - return - output_text = RemoteRunner._extract_responses_output_text(data) - if output_text and event_type != "response.completed": - yield {"delta": output_text, "type": "text"} + # 未知事件默认丢弃(对标 hosted UI responses-stream.js: 未知事件 return [])。 + # 之前默认把 data.delta 当 text 显示,会导致 reasoning 走未识别事件时漏进正文。 + # 正文已由 output_text.delta / content_part.delta 显式处理,未知事件不该当正文。 diff --git a/ksadk/server/app.py b/ksadk/server/app.py index 099fd0b4..904fd8dd 100644 --- a/ksadk/server/app.py +++ b/ksadk/server/app.py @@ -1064,6 +1064,16 @@ class ListSessionEventsActionRequest(BaseModel): 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 @@ -1596,6 +1606,82 @@ def _apply_checkpoint_resume_audit( 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", @@ -1729,6 +1815,7 @@ async def _find_session_checkpoint( continue if checkpoint["CheckpointId"] != checkpoint_id: continue + checkpoint = _check_adk_latest_resumable(checkpoint, events) return checkpoint return None @@ -1951,6 +2038,12 @@ async def get_agent_ui_bootstrap(request: UiBootstrapRequest): 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", { @@ -1966,17 +2059,17 @@ async def get_agent_ui_bootstrap(request: UiBootstrapRequest): "WorkspaceFiles": workspace_enabled, "Approval": True, "Thinking": True, - "StopRun": True, - "ResumeRun": True, + "StopRun": cancel_run_supported, + "ResumeRun": checkpoint_resume_supported, "RuntimeCapabilities": runtime_capabilities, "CheckpointResumeCapability": checkpoint_resume_capability, "RunLifecycle": { "Enabled": True, "Resume": True, "Abort": True, - "Checkpoints": True, - "CheckpointResume": True, - "CheckpointResumePreview": True, + "Checkpoints": checkpoint_resume_supported, + "CheckpointResume": checkpoint_resume_supported, + "CheckpointResumePreview": checkpoint_resume_supported, }, "MCP": False, "HostedRuntime": False, @@ -2083,6 +2176,51 @@ async def list_session_events_action(request: ListSessionEventsActionRequest): ) +@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 数量。 @@ -2127,6 +2265,7 @@ async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest 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] @@ -2202,6 +2341,8 @@ async def get_checkpoint_resume_preview_action(request: GetCheckpointResumePrevi 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)}, @@ -2367,7 +2508,8 @@ async def event_generator() -> AsyncIterator[str]: yield "data: [DONE]\n\n" return - # 重连兜底:本轮无新事件时,查全量确认 run 是否已有 terminal(客户端断连期间 run 已结束)。 + # 重连兜底:本轮无新事件时,查全量确认 run 是否已有 terminal + # (客户端断连期间 run 已结束)。 # 正常流式期间不触发此查询,保持增量收益。 if not matched_events: all_events = await service.get_events(session_id) diff --git a/ksadk/sessions/__init__.py b/ksadk/sessions/__init__.py index 5ba828cd..8aeffe06 100644 --- a/ksadk/sessions/__init__.py +++ b/ksadk/sessions/__init__.py @@ -134,30 +134,23 @@ def _create_postgres_backend( if not config.dsn: raise ValueError("KSADK_SESSION_DSN is required when KSADK_SESSION_BACKEND=postgres") from ksadk.sessions.postgres_service import PostgresSessionService - - return PostgresSessionService( - dsn=config.dsn, - namespace=config.namespace, - tenant_id=config.tenant_id, - workspace_id=config.workspace_id, - connect_timeout=_postgres_connect_timeout_seconds(), + from ksadk.sessions.resilient import ResilientSessionService + + return ResilientSessionService( + PostgresSessionService( + dsn=config.dsn, + namespace=config.namespace, + tenant_id=config.tenant_id, + workspace_id=config.workspace_id, + connect_timeout=_postgres_connect_timeout_seconds(), + ) ) def _postgres_connect_timeout_seconds() -> float: - raw = ( - os.getenv("KSADK_SESSION_CONNECT_TIMEOUT") - or os.getenv("KSADK_SESSION_PG_CONNECT_TIMEOUT") - or "" - ).strip() - if not raw: - return 5.0 - try: - value = float(raw) - except ValueError: - logger.warning("Invalid KSADK_SESSION_CONNECT_TIMEOUT=%r; using 5 seconds", raw) - return 5.0 - return max(0.1, value) + from ksadk.sessions.resilience import session_backend_timeout_seconds + + return session_backend_timeout_seconds() def _register_builtin_backends() -> None: @@ -168,12 +161,15 @@ def _register_builtin_backends() -> None: def describe_session_backend(*, backend: str | None = None) -> dict[str, object]: config = resolve_session_backend_config(backend=backend) - return { + payload: dict[str, object] = { "Backend": config.backend, "Shared": config.backend == "postgres", "ProductionSafe": config.backend == "postgres", "ContinuityDefault": "semantic/replay" if config.backend == "postgres" else "local_only", } + if config.backend == "postgres": + payload.update({"FailureMode": "fail_open", "FallbackBackend": "memory"}) + return payload def log_session_backend_diagnostics(*, backend: str | None = None) -> None: diff --git a/ksadk/sessions/continuity.py b/ksadk/sessions/continuity.py index 7f33e35d..ea8c4562 100644 --- a/ksadk/sessions/continuity.py +++ b/ksadk/sessions/continuity.py @@ -79,7 +79,9 @@ async def set_binding_by_session_id( ) return dict(state.state) - async def get_runtime_state_by_session_id(self, session_id: str, runner_key: str) -> dict[str, Any]: + async def get_runtime_state_by_session_id( + self, session_id: str, runner_key: str + ) -> dict[str, Any]: session = await self._load_session(session_id) if session is None: return {} @@ -271,7 +273,11 @@ def continuity_status( else: path = "replay" return SessionContinuityStatus( - level=SessionContinuityLevel.RUNTIME if has_checkpointer else SessionContinuityLevel.SEMANTIC, + level=( + SessionContinuityLevel.RUNTIME + if has_checkpointer + else SessionContinuityLevel.SEMANTIC + ), path=path, runner=self.runner_key(runner), ) @@ -306,8 +312,26 @@ def continuity_status( "KSADK_SESSION_DSN", ) ) + is_resumable = bool(getattr(runner, "_resumable", False)) + if is_resumable: + # P1.3: Level must degrade with backend — in-memory session state + # cannot survive pod restarts, so RUNTIME is misleading. + _stm = getattr(runner, "_short_term_memory", None) + 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 + ) + path = "adk_resume" + elif has_native_session: + level = SessionContinuityLevel.SEMANTIC + path = "native_session" + else: + level = SessionContinuityLevel.SEMANTIC + path = "replay" return SessionContinuityStatus( - level=SessionContinuityLevel.SEMANTIC, - path="native_session" if has_native_session else "replay", + level=level, + path=path, runner=self.runner_key(runner), ) diff --git a/ksadk/sessions/postgres_service.py b/ksadk/sessions/postgres_service.py index 31727dee..ed556069 100644 --- a/ksadk/sessions/postgres_service.py +++ b/ksadk/sessions/postgres_service.py @@ -4,6 +4,7 @@ import asyncio import json +import logging import time from typing import Any, Optional from urllib.parse import urlsplit, urlunsplit @@ -20,6 +21,9 @@ KSADK_PG_SESSIONS_TABLE = "ksadk_sessions" KSADK_PG_EVENTS_TABLE = "ksadk_events" KSADK_PG_STATES_TABLE = "ksadk_states" +PG_READABLE_EVENTS_VIEW = "ksadk_session_events_readable" + +logger = logging.getLogger(__name__) class PostgresSessionService(BaseSessionService): @@ -70,6 +74,7 @@ async def create_session( 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 """, self.namespace, self.tenant_id, @@ -81,6 +86,13 @@ async def create_session( now, now, ) + persisted = await self._get_session_with_connection( + connection, + session_key, + for_update=True, + ) + if persisted is None: + raise RuntimeError(f"Failed to create Postgres session {session_key}") await connection.execute( f""" INSERT INTO {KSADK_PG_STATES_TABLE} ( @@ -93,19 +105,13 @@ async def create_session( self.namespace, self.tenant_id, self.workspace_id, - agent_id, - user_id, + persisted.agent_id, + persisted.user_id, session_key, "{}", now, ) - return Session( - id=session_key, - agent_id=agent_id, - user_id=user_id, - created_at=now, - updated_at=now, - ) + return persisted async def get_session(self, session_id: str) -> Optional[Session]: await self._ensure_schema() @@ -545,10 +551,15 @@ async def update_state( ) async def aclose(self) -> None: - if self._pool is not None: - await self._pool.close() - self._pool = None - self._schema_ready = False + pool = self._pool + self._pool = None + self._schema_ready = False + if pool is None: + return + try: + await asyncio.wait_for(pool.close(), timeout=self.connect_timeout) + except (TimeoutError, OSError, ConnectionError, asyncio.TimeoutError): + pool.terminate() async def _ensure_pool(self) -> None: if self._pool is not None: @@ -559,7 +570,7 @@ async def _ensure_pool(self) -> None: try: import asyncpg except ImportError as exc: - raise RuntimeError( + raise SessionBackendUnavailable( "asyncpg is required for KSADK_SESSION_BACKEND=postgres" ) from exc try: @@ -568,6 +579,7 @@ async def _ensure_pool(self) -> None: min_size=self.min_size, max_size=self.max_size, timeout=self.connect_timeout, + command_timeout=self.connect_timeout, ) except (TimeoutError, OSError, ConnectionError, asyncio.TimeoutError) as exc: raise SessionBackendUnavailable( @@ -656,6 +668,57 @@ async def _ensure_schema(self) -> None: ADD COLUMN IF NOT EXISTS workspace_id TEXT NOT NULL DEFAULT 'default'; """ ) + try: + await connection.execute( + f""" + CREATE OR REPLACE VIEW {PG_READABLE_EVENTS_VIEW} AS + SELECT + event_row.namespace, + event_row.tenant_id, + event_row.workspace_id, + session_row.agent_id, + session_row.user_id, + session_row.title AS session_title, + event_row.session_id, + event_row.seq_id, + event_row.id AS event_id, + event_row.invocation_id, + event_row.author, + event_row.event_type, + CASE + WHEN event_row.event_type = 'user_message' THEN 'user' + WHEN event_row.event_type IN ( + 'assistant_message', 'reasoning', 'tool_call' + ) THEN 'assistant' + WHEN event_row.event_type = 'tool_result' THEN 'tool' + ELSE NULL + END AS message_role, + COALESCE( + NULLIF(event_row.content_json #>> '{{parts,0,text}}', ''), + NULLIF(event_row.content_json ->> 'text', ''), + NULLIF(event_row.metadata_json ->> 'reasoning', ''), + NULLIF(event_row.metadata_json ->> 'tool_output', '') + ) AS message_text, + event_row.metadata_json ->> 'tool_name' AS tool_name, + CASE + WHEN event_row.event_type = 'run_status' THEN COALESCE( + event_row.content_json ->> 'status', + event_row.metadata_json ->> 'status' + ) + ELSE NULL + END AS lifecycle_status, + to_timestamp(event_row.timestamp) AS created_at, + event_row.content_json, + event_row.state_delta_json, + event_row.metadata_json + FROM {KSADK_PG_EVENTS_TABLE} AS event_row + 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 async def _get_session_with_connection( diff --git a/ksadk/sessions/resilience.py b/ksadk/sessions/resilience.py new file mode 100644 index 00000000..30049fff --- /dev/null +++ b/ksadk/sessions/resilience.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import logging +import os +from collections.abc import Iterator + +from ksadk.sessions.errors import SessionBackendUnavailable + +logger = logging.getLogger(__name__) + + +def session_backend_timeout_seconds() -> float: + raw = ( + os.getenv("KSADK_SESSION_CONNECT_TIMEOUT") + or os.getenv("KSADK_SESSION_PG_CONNECT_TIMEOUT") + or "" + ).strip() + if not raw: + return 5.0 + try: + value = float(raw) + except ValueError: + logger.warning("Invalid KSADK_SESSION_CONNECT_TIMEOUT=%r; using 5 seconds", raw) + return 5.0 + return max(0.1, value) + + +def _exception_chain(exc: BaseException) -> Iterator[BaseException]: + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + yield current + original = getattr(current, "orig", None) + if isinstance(original, BaseException) and id(original) not in seen: + current = original + continue + current = current.__cause__ or current.__context__ + + +def is_session_backend_failure(exc: BaseException) -> bool: + """Return whether an exception represents an unavailable database backend.""" + for current in _exception_chain(exc): + if isinstance( + current, + (SessionBackendUnavailable, TimeoutError, OSError, ConnectionError), + ): + return True + module = type(current).__module__ + if module.startswith("asyncpg.") or module.startswith("sqlalchemy."): + return True + return False diff --git a/ksadk/sessions/resilient.py b/ksadk/sessions/resilient.py new file mode 100644 index 00000000..a6df6279 --- /dev/null +++ b/ksadk/sessions/resilient.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Optional, cast + +from ksadk.sessions.base import BaseSessionService, Session, SessionEvent, SessionState +from ksadk.sessions.in_memory import InMemorySessionService +from ksadk.sessions.resilience import is_session_backend_failure + +logger = logging.getLogger(__name__) + + +class ResilientSessionService(BaseSessionService): + """Keep live agent sessions available when durable persistence is unavailable. + + The in-memory service is authoritative for the lifetime of this process. The + configured durable service is used as a read-through source and a best-effort + write-through sink. After its first failure it stays disabled until a + background probe confirms the durable backend is reachable again, at which + point it is re-enabled and an INFO log is emitted. + """ + + _probe_interval_seconds: float = 30.0 + + def __init__( + self, + primary: BaseSessionService, + fallback: InMemorySessionService | None = None, + ) -> None: + self.primary = primary + self.fallback = fallback or InMemorySessionService() + self._primary_enabled = True + self._hydrate_lock = asyncio.Lock() + self._primary_session_lock = asyncio.Lock() + self._primary_session_ids: set[str] = set() + self._probe_task: asyncio.Task[None] | None = None + + @property + def degraded(self) -> bool: + return not self._primary_enabled + + async def _call_primary(self, method_name: str, *args: Any, **kwargs: Any) -> tuple[bool, Any]: + if not self._primary_enabled: + return False, None + try: + method = getattr(self.primary, method_name) + return True, await method(*args, **kwargs) + except Exception as exc: + if not is_session_backend_failure(exc): + raise + self._disable_primary(exc) + return False, None + + def _disable_primary(self, exc: Exception) -> None: + if not self._primary_enabled: + return + self._primary_enabled = False + logger.error( + "KSADK session persistence degraded; using in-memory live session: %s", + exc, + extra={ + "session_backend_state": "degraded", + "session_backend": type(self.primary).__name__, + }, + ) + self._start_probe() + + def _start_probe(self) -> None: + if self._probe_task is not None and not self._probe_task.done(): + return + self._probe_task = asyncio.create_task(self._probe_loop()) + + async def _probe_loop(self) -> None: + while not self._primary_enabled: + await asyncio.sleep(self._probe_interval_seconds) + if self._primary_enabled: + break + try: + await self.primary.get_session("__ksadk_probe__") + except Exception: + continue + self._primary_enabled = True + logger.info( + "KSADK session persistence recovered; durable backend re-enabled", + extra={ + "session_backend_state": "recovered", + "session_backend": type(self.primary).__name__, + }, + ) + + async def _hydrate(self, session: Session) -> Session: + async with self._hydrate_lock: + self._primary_session_ids.add(session.id) + existing = await self.fallback.get_session(session.id) + if existing is None: + await self.fallback.create_session( + session.agent_id, + session.user_id, + session_id=session.id, + ) + existing_events = await self.fallback.get_events(session.id) + existing_ids = {event.id for event in existing_events} + for event in sorted(session.events, key=lambda item: item.seq_id): + if event.id not in existing_ids: + await self.fallback.append_event(session.id, event) + await self.fallback.update_session_metadata( + session.id, + title=session.title, + title_source=session.title_source, + summary=session.summary, + first_prompt=session.first_prompt, + last_prompt=session.last_prompt, + ) + current = await self.fallback.get_session(session.id) + if current is not None and session.state != current.state: + await self.fallback.update_state( + agent_id=session.agent_id, + user_id=session.user_id, + session_id=session.id, + scope="session", + state_delta=session.state, + ) + hydrated = await self.fallback.get_session(session.id) + if hydrated is None: + raise RuntimeError(f"Failed to hydrate live session {session.id}") + return hydrated + + async def create_session( + self, + agent_id: str, + user_id: str, + session_id: Optional[str] = None, + ) -> Session: + if session_id: + ok, durable = await self._call_primary("get_session", session_id) + if ok and durable is not None: + return await self._hydrate(durable) + existing = await self.fallback.get_session(session_id) + if existing is not None: + return existing + + live = await self.fallback.create_session(agent_id, user_id, session_id=session_id) + ok, durable = await self._call_primary( + "create_session", + agent_id, + user_id, + session_id=live.id, + ) + if ok and durable is not None: + self._primary_session_ids.add(durable.id) + return await self._hydrate(durable) + return live + + async def get_session(self, session_id: str) -> Optional[Session]: + live = await self.fallback.get_session(session_id) + ok, durable = await self._call_primary("get_session", session_id) + if ok and durable is not None: + return await self._hydrate(durable) + return live + + async def list_sessions( + self, + agent_id: str, + user_id: Optional[str] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[Session]: + ok, durable_sessions = await self._call_primary( + "list_sessions", + agent_id, + user_id, + offset, + 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), + ) + + 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) + + async def delete_session(self, session_id: str) -> bool: + deleted = await self.fallback.delete_session(session_id) + ok, durable_deleted = await self._call_primary("delete_session", session_id) + if ok: + self._primary_session_ids.discard(session_id) + return deleted or bool(durable_deleted) if ok else deleted + + async def update_session_metadata( + self, + session_id: str, + *, + title: Optional[str] = None, + title_source: Optional[str] = None, + summary: Optional[str] = None, + first_prompt: Optional[str] = None, + last_prompt: Optional[str] = None, + ) -> Session: + if await self.fallback.get_session(session_id) is None: + await self.get_session(session_id) + live = await self.fallback.update_session_metadata( + session_id, + title=title, + title_source=title_source, + summary=summary, + first_prompt=first_prompt, + last_prompt=last_prompt, + ) + await self._ensure_primary_session(session_id) + await self._call_primary( + "update_session_metadata", + session_id, + title=title, + title_source=title_source, + summary=summary, + first_prompt=first_prompt, + last_prompt=last_prompt, + ) + return live + + async def _ensure_primary_session(self, session_id: str) -> None: + """Create the session in PG if it only exists in memory (degraded-era).""" + if not self._primary_enabled or session_id in self._primary_session_ids: + return + async with self._primary_session_lock: + if not self._primary_enabled or session_id in self._primary_session_ids: + return + ok, durable = await self._call_primary("get_session", session_id) + if not ok: + return + if durable is None: + live = await self.fallback.get_session(session_id) + if live is None: + return + ok, durable = await self._call_primary( + "create_session", + live.agent_id, + live.user_id, + session_id=live.id, + ) + if not ok or durable is None: + return + self._primary_session_ids.add(session_id) + + async def append_event(self, session_id: str, event: SessionEvent) -> SessionEvent: + if await self.fallback.get_session(session_id) is None: + await self.get_session(session_id) + live = await self.fallback.append_event(session_id, event) + await self._ensure_primary_session(session_id) + await self._call_primary("append_event", session_id, event) + return live + + async def get_events( + self, + session_id: str, + offset: Optional[int] = None, + limit: Optional[int] = None, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, + ) -> list[SessionEvent]: + await self.get_session(session_id) + return cast( + list[SessionEvent], + await self.fallback.get_events( + session_id, + offset, + limit, + after_seq_id, + before_seq_id, + ), + ) + + async def count_events( + self, + session_id: str, + after_seq_id: Optional[int] = None, + before_seq_id: Optional[int] = None, + ) -> int: + await self.get_session(session_id) + return cast( + int, + await self.fallback.count_events(session_id, after_seq_id, before_seq_id), + ) + + async def get_state( + self, + agent_id: str, + user_id: Optional[str], + session_id: Optional[str], + scope: str = "session", + ) -> Optional[SessionState]: + if session_id: + await self.get_session(session_id) + live = await self.fallback.get_state(agent_id, user_id, session_id, scope) + if live is not None or not self._primary_enabled: + return live + ok, durable = await self._call_primary( + "get_state", + agent_id, + user_id, + session_id, + scope, + ) + if ok and durable is not None: + return await self.fallback.update_state( + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + scope=scope, + state_delta=durable.state, + ) + return live + + async def update_state( + self, + *, + agent_id: str, + user_id: Optional[str], + session_id: Optional[str], + scope: str, + state_delta: dict[str, Any], + ) -> SessionState: + if session_id and await self.fallback.get_session(session_id) is None: + await self.get_session(session_id) + live = await self.fallback.update_state( + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + scope=scope, + state_delta=state_delta, + ) + if session_id: + await self._ensure_primary_session(session_id) + await self._call_primary( + "update_state", + agent_id=agent_id, + user_id=user_id, + session_id=session_id, + scope=scope, + state_delta=state_delta, + ) + return live + + async def aclose(self) -> None: + if self._probe_task is not None and not self._probe_task.done(): + self._probe_task.cancel() + try: + await self._probe_task + except asyncio.CancelledError: + pass + for service in (self.primary, self.fallback): + close = getattr(service, "aclose", None) + if close is not None: + await close() diff --git a/ksadk/toolsets/__init__.py b/ksadk/toolsets/__init__.py index 609f04a6..7daeeecb 100644 --- a/ksadk/toolsets/__init__.py +++ b/ksadk/toolsets/__init__.py @@ -53,6 +53,8 @@ "workspace_status", "list_workspace_files", "read_workspace_file", + "write_workspace_file", + "write_workspace_files", "search_workspace_files", "edit_workspace_file", "multi_edit_workspace_file", diff --git a/ksadk/toolsets/workspace.py b/ksadk/toolsets/workspace.py index af3b7d8a..38f719d1 100644 --- a/ksadk/toolsets/workspace.py +++ b/ksadk/toolsets/workspace.py @@ -150,7 +150,10 @@ def read_workspace_file( max_chars: int | None = None, include_line_numbers: bool = True, ) -> dict[str, Any]: - """Read a UTF-8 text file from the AgentEngine workspace.""" + """Read a UTF-8 workspace file. Inspect the result before editing it. + + Do not issue a dependent read and edit in the same parallel tool-call batch. + """ return _gateway().invoke( "read_workspace_file", @@ -313,7 +316,10 @@ def edit_workspace_file( replace_all: bool = False, approval: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Replace an exact text snippet inside a UTF-8 workspace file.""" + """Replace exact text after reading the file in a prior model step. + + The read must complete before this call; do not batch a dependent read and edit. + """ return _gateway().invoke( "edit_workspace_file", @@ -336,6 +342,9 @@ def _edit_workspace_file_impl( ) -> dict[str, Any]: target = resolve_workspace_path(path) relative = workspace_relative(target) + request_error = _validate_single_edit_request(old_text, relative=relative) + if request_error is not None: + return request_error read_error = _validate_read_state(target, relative) if read_error is not None: return read_error @@ -376,7 +385,10 @@ def multi_edit_workspace_file( replace_all: bool = False, approval: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Apply multiple exact snippet edits atomically inside one workspace file.""" + """Apply atomic exact edits after reading the file in a prior model step. + + The read must complete before this call; do not batch a dependent read and edit. + """ return _gateway().invoke( "multi_edit_workspace_file", @@ -395,21 +407,36 @@ def _multi_edit_workspace_file_impl( ) -> dict[str, Any]: target = resolve_workspace_path(path) relative = workspace_relative(target) + if not isinstance(edits, list) or not edits: + return { + "ok": False, + "path": relative, + "error_type": "invalid_edits", + "error_message": "edits must be a non-empty list", + } + for index, edit in enumerate(edits): + if not isinstance(edit, dict): + return { + "ok": False, + "path": relative, + "error_type": "invalid_edit", + "error_message": f"edit at index {index} must be an object", + "failed_edit_index": index, + } + request_error = _validate_single_edit_request(edit.get("old_text"), relative=relative) + if request_error is not None: + return {**request_error, "failed_edit_index": index, "edit_count": len(edits)} read_error = _validate_read_state(target, relative) if read_error is not None: return read_error text, error = _read_workspace_text(target) if error is not None: return {**error, "path": path} - if not isinstance(edits, list) or not edits: - return {"ok": False, "path": relative, "error_type": "invalid_edits", "error_message": "edits must be a non-empty list"} original = text or "" updated = original diagnostics: list[dict[str, Any]] = [] total_replacements = 0 for index, edit in enumerate(edits): - if not isinstance(edit, dict): - return {"ok": False, "path": relative, "error_type": "invalid_edit", "error_message": f"edit at index {index} must be an object", "failed_edit_index": index} edit_result = _apply_single_edit( updated, edit.get("old_text"), @@ -718,9 +745,10 @@ def _apply_single_edit( replace_all: bool = False, relative: str, ) -> dict[str, Any]: - old = str(old_text or "") - if not old: - return {"ok": False, "path": relative, "error_type": "missing_old_text", "error_message": "old_text is required"} + request_error = _validate_single_edit_request(old_text, relative=relative) + if request_error is not None: + return request_error + old = str(old_text) expected = max(1, int(expected_replacements or 1)) actual_old, used_quote_normalization = _find_actual_string(text, old) matches = _match_previews(text, actual_old or old) if actual_old else [] @@ -756,6 +784,17 @@ def _apply_single_edit( } +def _validate_single_edit_request(old_text: Any, *, relative: str) -> dict[str, Any] | None: + if not str(old_text or ""): + return { + "ok": False, + "path": relative, + "error_type": "missing_old_text", + "error_message": "old_text is required", + } + return None + + def _match_previews(text: str, needle: str, limit: int = 20) -> list[dict[str, Any]]: if not needle: return [] diff --git a/ksadk/tracing/setup.py b/ksadk/tracing/setup.py index 17b043ba..34a9c816 100644 --- a/ksadk/tracing/setup.py +++ b/ksadk/tracing/setup.py @@ -174,6 +174,16 @@ def _has_nonzero_token_usage(usage: dict[str, int]) -> bool: return any(value > 0 for value in usage.values()) +def _instrumentation_scope_name(span: Any) -> str: + scope = getattr(span, "instrumentation_scope", None) + name = getattr(scope, "name", None) + if name: + return str(name) + info = getattr(span, "instrumentation_info", None) + name = getattr(info, "name", None) + return str(name or "") + + def _clone_span_with_attributes(span: Any, attributes: dict[str, Any]) -> Any: try: from opentelemetry.sdk.trace import ReadableSpan @@ -198,6 +208,53 @@ def _clone_span_with_attributes(span: Any, attributes: dict[str, Any]) -> Any: return span +def _prepare_langfuse_spans(spans: Any) -> Any: + """Keep ksadk response usage authoritative for Langfuse exports. + + OpenInference LangChain instrumentation can emit llm.token_count.* on nested + ChatOpenAI spans. In LangGraph streaming these counts can be cumulative and + disagree with the usage returned by ksadk to RunAgent/session state. When a + trace already has a ksadk.conversations span with gen_ai.usage.*, strip token + attrs from nested OpenInference LangChain spans before exporting to + Langfuse. The child spans remain useful for timing and structure, but cannot + pollute token accounting. + """ + if not spans: + return spans + span_list = list(spans) + authoritative_trace_ids = { + _trace_id(span) + for span in span_list + if _trace_id(span) is not None + and _instrumentation_scope_name(span) == "ksadk.conversations" + and _has_nonzero_token_usage(_span_token_usage(span)) + } + if not authoritative_trace_ids: + return spans + + strip_prefixes = ("llm.token_count.", "llm.usage.", "gen_ai.usage.") + replacements: dict[int, Any] = {} + for span in span_list: + if _trace_id(span) not in authoritative_trace_ids: + continue + if _instrumentation_scope_name(span) != "openinference.instrumentation.langchain": + continue + attributes = dict(getattr(span, "attributes", None) or {}) + stripped = { + key: value + for key, value in attributes.items() + if not any(str(key).startswith(prefix) for prefix in strip_prefixes) + } + if len(stripped) != len(attributes): + if str(stripped.get("openinference.span.kind") or "").upper() == "LLM": + stripped["openinference.span.kind"] = "CHAIN" + replacements[id(span)] = _clone_span_with_attributes(span, stripped) + + if not replacements: + return spans + return [replacements.get(id(span), span) for span in span_list] + + def _prepare_cloud_monitor_spans(spans: Any) -> Any: """Add root token rollups for CloudMonitor without changing other exporters. @@ -634,6 +691,11 @@ def setup_tracing( endpoint=generic_otlp_config["endpoint"], service_name=_get_service_name(), header_keys=sorted(generic_otlp_config["headers"]), + span_transform=( + _prepare_langfuse_spans + if _is_langfuse_otlp_endpoint(generic_otlp_config["endpoint"]) + else None + ), ) provider.add_span_processor( BatchSpanProcessor( @@ -721,6 +783,7 @@ def setup_tracing( endpoint=config["endpoint"], service_name=_get_service_name(), header_keys=sorted(config["headers"]), + span_transform=_prepare_langfuse_spans, ) provider.add_span_processor( BatchSpanProcessor( diff --git a/ksadk/tui/__init__.py b/ksadk/tui/__init__.py index 35686855..3815dc4e 100644 --- a/ksadk/tui/__init__.py +++ b/ksadk/tui/__init__.py @@ -1,12 +1,27 @@ """ -KsADK TUI - Textual Terminal User Interface +KsADK TUI - prompt_toolkit 全屏交互(对标 Codex CLI) -提供简洁的交互体验: -- 流式 Markdown 输出 -- 历史记录支持 -- 思考过程显示 +- 全屏 alternate screen,transcript 区(FormattedTextControl + ANSI 保留 rich 颜色) + + 底部输入框 + footer 状态栏 +- 滚动:PageUp/PgDn/↑↓(cursor 跟随视口顶 + 手动 vertical_scroll) +- 流式:streaming 期动态区累积,turn 结束落定整条 rich 渲染(历史 ANSI 缓存) +- 命令:/new /clear /session /model(模型热切 picker)? exit;Ctrl-C 取消流式/退出 """ -from ksadk.tui.app import AgentTUI +from ksadk.tui.loop import ( + InteractionLoop, + InterruptPending, + RichLiveRenderer, + TranscriptEntry, + render_stream, + run_tui, +) -__all__ = ["AgentTUI"] +__all__ = [ + "InteractionLoop", + "InterruptPending", + "RichLiveRenderer", + "TranscriptEntry", + "render_stream", + "run_tui", +] diff --git a/ksadk/tui/app.py b/ksadk/tui/app.py deleted file mode 100644 index 69539f56..00000000 --- a/ksadk/tui/app.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -KsADK Agent TUI - 简洁交互界面 - -基本功能: -- 对话交互 -- 思考过程显示(通过 --show-thinking 参数控制) -- 工具调用显示 -- 退出:输入 exit/quit 或 Ctrl+C/Ctrl+D -""" - -from __future__ import annotations - -import asyncio -import os -import uuid -import re -from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional - -from textual import on -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Container, Vertical, VerticalScroll -from textual.screen import ModalScreen -from textual.widgets import Static -from textual.events import MouseUp - -from rich.panel import Panel - -from ksadk.tui.clipboard import copy_selection_to_clipboard -from ksadk.tui.widgets.user import UserMessage -from ksadk.tui.widgets.assistant import AssistantMessage -from ksadk.tui.widgets.thinking import ThinkingMessage -from ksadk.tui.widgets.system import SystemMessage -from ksadk.tui.widgets.chat_input import ChatInput - -if TYPE_CHECKING: - from ksadk.runners.base_runner import BaseRunner - - -def _clean_response(text: str) -> str: - """清理 LLM 响应中的内部调试信息""" - text = re.sub(r'\[Tool Result:.*?\]', '', text, flags=re.DOTALL) - text = re.sub(r'.*?', '', text, flags=re.DOTALL) - text = re.sub(r"name='[^']*'\s*tool_call_id='[^']*'", '', text) - return text.strip() - - -class ApprovalScreen(ModalScreen[bool]): - """敏感操作确认弹窗""" - - BINDINGS = [ - Binding("y", "approve", "确认"), - Binding("n", "reject", "取消"), - Binding("escape", "reject", "取消"), - ] - - def __init__(self, tool_name: str, args: Dict[str, Any], **kwargs): - super().__init__(**kwargs) - self.tool_name = tool_name - self.tool_args = args - - def compose(self) -> ComposeResult: - args_str = "\n".join(f" {k}: {v}" for k, v in self.tool_args.items()) if self.tool_args else " (无参数)" - yield Container( - Static( - Panel( - f"[bold yellow]⚠️ 需要您确认敏感操作[/]\n\n" - f"[bold]操作:[/] {self.tool_name}\n" - f"[bold]参数:[/]\n{args_str}\n\n" - f"[green]y[/] 确认 [red]n[/] 取消", - title="🔒 确认", - border_style="yellow", - ), - ), - id="approval-dialog", - ) - - def action_approve(self) -> None: - self.dismiss(True) - - def action_reject(self) -> None: - self.dismiss(False) - - CSS = """ - #approval-dialog { - align: center middle; - width: 60; - height: auto; - } - """ - - -class AgentTUI(App): - """KsADK Agent TUI - 简洁交互界面""" - - CSS = """ - Screen { - background: $background; - } - - #main-scroll { - height: 100%; - padding: 0 1; - } - - #title-bar { - height: 1; - color: $primary; - padding: 0; - } - - #welcome-area { - height: auto; - padding: 2 0; - content-align: center middle; - text-align: center; - } - - #chat-log { - height: auto; - background: transparent; - padding: 0; - } - - #input-area { - height: auto; - padding: 1 0; - } - - #hint { - height: 1; - color: $text-muted; - padding: 0 0 0 2; - } - - .started #welcome-area { - display: none; - } - """ - - BINDINGS = [ - Binding("escape", "interrupt", "中断", show=False, priority=True), - Binding("ctrl+c", "quit_or_interrupt", "退出/中断", show=False), - Binding("ctrl+d", "quit_app", "退出", show=False, priority=True), - Binding("ctrl+q", "quit_app", "退出", show=False), - ] - - TITLE = "KsADK" - - def __init__( - self, - runner: "BaseRunner", - show_thinking: bool = False, - project_dir: str = ".", - **kwargs, - ): - super().__init__(**kwargs) - self.runner = runner - self.show_thinking = show_thinking - self.project_dir = Path(project_dir).resolve() - - self.session_id = getattr(runner, "session_id", None) or str(uuid.uuid4())[:8] - if getattr(self.runner, "session_id", None) is None: - self.runner.session_id = self.session_id - self.history: List[Dict[str, str]] = [] - self._is_streaming = False - self._started = False - self.model_name = os.getenv("MODEL_NAME", "unknown") - - try: - from ksadk import __version__ - self.version = __version__ - except ImportError: - self.version = "0.2.0" - - def compose(self) -> ComposeResult: - """构建 UI 布局""" - with VerticalScroll(id="main-scroll"): - yield Static(f"─ KsADK v{self.version} ─", id="title-bar") - - yield Container( - Static( - f"[bold]Welcome![/]\n" - f"🤖\n" - f"[dim]{self.model_name} · Interactive Mode[/]\n" - f"[dim]{self._short_path()}[/]", - id="welcome-content", - ), - id="welcome-area", - ) - - with Vertical(id="chat-log"): - pass - - yield Container( - ChatInput(cwd=self.project_dir, id="chat-input-widget"), - Static("Ctrl+C 退出", id="hint"), - id="input-area", - ) - - def _short_path(self) -> str: - home = Path.home() - try: - return "~/" + str(self.project_dir.relative_to(home)) - except ValueError: - return str(self.project_dir) - - def on_mount(self) -> None: - self.query_one("#chat-input-widget", ChatInput).focus_input() - - @on(ChatInput.Submitted) - async def handle_input(self, event: ChatInput.Submitted) -> None: - """处理用户输入""" - user_input = event.value.strip() - - if not user_input: - return - - # 检查退出 - if user_input.lower() in ("exit", "quit", "退出"): - self.exit() - return - - # 隐藏欢迎区域 - if not self._started: - self._started = True - self.add_class("started") - - asyncio.create_task(self._stream_response(user_input)) - - async def _stream_response(self, user_input: str) -> None: - """流式获取 Agent 响应""" - chat_log = self.query_one("#chat-log", Vertical) - main_scroll = self.query_one("#main-scroll", VerticalScroll) - - self._is_streaming = True - - input_data = { - "input": user_input, - "session_id": self.session_id, - "history": self.history, - } - - thinking_msg: Optional[ThinkingMessage] = None - assistant_msg: Optional[AssistantMessage] = None - full_response_text = "" - - try: - await chat_log.mount(UserMessage(user_input)) - main_scroll.scroll_end(animate=False) - - async for chunk in self.runner.stream(input_data): - if not self._is_streaming: - if thinking_msg: - thinking_msg.stop() - if assistant_msg: - await assistant_msg.stop_stream() - await chat_log.mount(SystemMessage("已中断", level="warning")) - break - - chunk_type = chunk.get("type", "text") - - # 思考过程 - if chunk_type == "thinking": - delta = chunk.get("delta", "") - if delta and self.show_thinking: - if not thinking_msg: - thinking_msg = ThinkingMessage() - await chat_log.mount(thinking_msg) - main_scroll.scroll_end(animate=False) - await thinking_msg.append_content(delta) - continue - - if thinking_msg and thinking_msg.is_active: - thinking_msg.stop() - - # 中断/确认 - if chunk_type == "interrupt": - await self._handle_interrupt(chunk) - return - - # 工具调用 - 跳过不显示 - if chunk_type == "tool_call": - continue - - # 文本响应 - delta = chunk.get("output", "") or chunk.get("delta", "") - if delta: - if not assistant_msg: - assistant_msg = AssistantMessage() - await chat_log.mount(assistant_msg) - main_scroll.scroll_end(animate=False) - await assistant_msg.append_content(delta) - full_response_text += delta - - if thinking_msg: - thinking_msg.stop() - if assistant_msg: - await assistant_msg.stop_stream() - - cleaned_response = _clean_response(full_response_text) - if cleaned_response: - self.history.append({"role": "user", "content": user_input}) - self.history.append({"role": "model", "content": cleaned_response}) - elif self._is_streaming and not full_response_text: - result = await self.runner.invoke(input_data) - response_text = result.get("output", "") - cleaned_response = _clean_response(response_text) - - if cleaned_response: - assistant_msg = AssistantMessage() - await chat_log.mount(assistant_msg) - await assistant_msg.append_content(cleaned_response) - await assistant_msg.stop_stream() - self.history.append({"role": "user", "content": user_input}) - self.history.append({"role": "model", "content": cleaned_response}) - else: - await chat_log.mount(SystemMessage("(无响应)", level="warning")) - - except asyncio.CancelledError: - await chat_log.mount(SystemMessage("已取消", level="warning")) - except Exception as e: - await chat_log.mount(SystemMessage(f"错误: {e}", level="error")) - finally: - self._is_streaming = False - main_scroll.scroll_end(animate=False) - - async def _handle_interrupt(self, interrupt_chunk: Dict[str, Any]) -> None: - """处理敏感操作确认""" - interrupt_info = interrupt_chunk.get("interrupt_info", {}) - - if isinstance(interrupt_info, dict): - tool_name = interrupt_info.get("tool", "未知操作") - args = interrupt_info.get("args", {}) - elif isinstance(interrupt_info, list) and interrupt_info: - first = interrupt_info[0] - tool_name = str(getattr(first, "value", first)) - args = {} - else: - tool_name = str(interrupt_info) - args = {} - - approved = await self.push_screen_wait(ApprovalScreen(tool_name, args)) - chat_log = self.query_one("#chat-log", Vertical) - - if approved: - await chat_log.mount(SystemMessage("✓ 已确认,继续执行...", level="success")) - - resume_data = { - "input": "确认", - "session_id": interrupt_chunk.get("session_id", self.session_id), - "history": self.history, - "resume": True, - } - - assistant_msg = AssistantMessage() - await chat_log.mount(assistant_msg) - - try: - async for chunk in self.runner.stream(resume_data): - delta = chunk.get("output", "") or chunk.get("delta", "") - if delta: - await assistant_msg.append_content(delta) - await assistant_msg.stop_stream() - if assistant_msg.content: - self.history.append({"role": "model", "content": assistant_msg.content}) - except Exception as e: - await chat_log.mount(SystemMessage(f"错误: {e}", level="error")) - else: - await chat_log.mount(SystemMessage("✗ 已取消操作", level="warning")) - - def action_interrupt(self) -> None: - self._is_streaming = False - - def action_quit_or_interrupt(self) -> None: - if self._is_streaming: - self.action_interrupt() - else: - self.exit() - - def action_quit_app(self) -> None: - self.exit() - - def on_mouse_up(self, event: MouseUp) -> None: - copy_selection_to_clipboard(self) - - -def run_tui( - runner: "BaseRunner", - show_thinking: bool = False, - project_dir: str = ".", -) -> None: - """启动 TUI 应用""" - app = AgentTUI( - runner=runner, - show_thinking=show_thinking, - project_dir=project_dir, - ) - app.run() diff --git a/ksadk/tui/clipboard.py b/ksadk/tui/clipboard.py deleted file mode 100644 index 8270735f..00000000 --- a/ksadk/tui/clipboard.py +++ /dev/null @@ -1,100 +0,0 @@ -"""剪贴板工具 - 支持鼠标选择复制""" - -from __future__ import annotations - -import base64 -import os -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from textual.app import App - -_PREVIEW_MAX_LENGTH = 40 - - -def _copy_osc52(text: str) -> None: - """使用 OSC 52 转义序列复制(支持 SSH/tmux)""" - encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") - osc52_seq = f"\033]52;c;{encoded}\a" - if os.environ.get("TMUX"): - osc52_seq = f"\033Ptmux;\033{osc52_seq}\033\\" - - try: - with open("/dev/tty", "w") as tty: - tty.write(osc52_seq) - tty.flush() - except Exception: - raise RuntimeError("OSC52 复制失败") - - -def _shorten_preview(texts: list[str]) -> str: - """缩短文本预览""" - dense_text = "⏎".join(texts).replace("\n", "⏎") - if len(dense_text) > _PREVIEW_MAX_LENGTH: - return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}…" - return dense_text - - -def _clipboard_copy_methods(app: App): - copy_methods = [] - if os.name != "nt": - copy_methods.append(_copy_osc52) - - try: - import pyperclip - copy_methods.append(pyperclip.copy) - except ImportError: - pass - - copy_methods.append(app.copy_to_clipboard) - return copy_methods - - -def copy_selection_to_clipboard(app: App) -> None: - """复制选中文本到剪贴板 - - 遍历所有 widgets 获取选中文本并复制到系统剪贴板 - """ - selected_texts = [] - - for widget in app.query("*"): - if not hasattr(widget, "text_selection") or not widget.text_selection: - continue - - selection = widget.text_selection - - try: - result = widget.get_selection(selection) - except Exception: - continue - - if not result: - continue - - selected_text, _ = result - if selected_text.strip(): - selected_texts.append(selected_text) - - if not selected_texts: - return - - combined_text = "\n".join(selected_texts) - - for copy_fn in _clipboard_copy_methods(app): - try: - copy_fn(combined_text) - app.notify( - f'"{_shorten_preview(selected_texts)}" 已复制', - severity="information", - timeout=2, - ) - return - except Exception: - continue - - # 所有方式都失败 - app.notify( - "复制失败 - 剪贴板不可用", - severity="warning", - timeout=3, - ) diff --git a/ksadk/tui/loop.py b/ksadk/tui/loop.py new file mode 100644 index 00000000..ad3364c6 --- /dev/null +++ b/ksadk/tui/loop.py @@ -0,0 +1,1877 @@ +"""Agent TUI based on a Codex-style inline prompt_toolkit application. + +Transcript 使用 ANSI 保留 rich 颜色和 Markdown 落定格式,composer 紧跟内容, +终端原生 scrollback 保留。支持键盘滚动、流式跟底和输入排队。 +""" +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from prompt_toolkit.completion import Completer + +from ksadk.tui.stream_render import clean_response, extract_stream_delta + +_TERMINAL_BG_PROBED = False +_TERMINAL_BG: tuple[int, int, int] | None = None + + +def _parse_terminal_rgb(response: str) -> tuple[int, int, int] | None: + """Parse OSC 11 ``rgb:rrrr/gggg/bbbb`` or ``#rrggbb`` responses.""" + import re + + match = re.search(r"(?:rgb:)([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", response) + if match: + channels: list[int] = [] + for value in match.groups(): + maximum = (16 ** len(value)) - 1 + channels.append(int(int(value, 16) / maximum * 255) if maximum else 0) + return channels[0], channels[1], channels[2] + match = re.search(r"#([0-9a-fA-F]{6})", response) + if match: + value = match.group(1) + return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16) + return None + + +def _terminal_background() -> tuple[int, int, int] | None: + """Query the terminal default background using the same OSC 11 signal as Codex.""" + global _TERMINAL_BG, _TERMINAL_BG_PROBED + if _TERMINAL_BG_PROBED: + return _TERMINAL_BG + _TERMINAL_BG_PROBED = True + + try: + import fcntl + import os + import select + import sys + import termios + import tty + + if os.name != "posix" or os.getenv("TERM", "") == "dumb": + return None + if not sys.stdin.isatty() or not sys.stdout.isatty(): + return None + fd = sys.stdin.fileno() + previous_termios = termios.tcgetattr(fd) + previous_flags = fcntl.fcntl(fd, fcntl.F_GETFL) + data = bytearray() + try: + tty.setcbreak(fd) + fcntl.fcntl(fd, fcntl.F_SETFL, previous_flags | os.O_NONBLOCK) + sys.stdout.write("\x1b]11;?\x1b\\") + sys.stdout.flush() + deadline = time.monotonic() + 0.12 + while time.monotonic() < deadline: + readable, _, _ = select.select([fd], [], [], deadline - time.monotonic()) + if not readable: + break + try: + chunk = os.read(fd, 128) + except BlockingIOError: + continue + if not chunk: + break + data.extend(chunk) + if b"\x07" in data or b"\x1b\\" in data: + break + finally: + fcntl.fcntl(fd, fcntl.F_SETFL, previous_flags) + termios.tcsetattr(fd, termios.TCSADRAIN, previous_termios) + _TERMINAL_BG = _parse_terminal_rgb(data.decode("ascii", errors="ignore")) + except Exception: + _TERMINAL_BG = None + return _TERMINAL_BG + + +def _codex_surface_rgb( + background: tuple[int, int, int] | None, + *, + composer: bool = False, +) -> tuple[int, int, int] | None: + """Match Codex's user-message/composer surface blend for light and dark terminals.""" + if background is None: + return None + r, g, b = background + is_light = (0.299 * r + 0.587 * g + 0.114 * b) > 128.0 + top = (0, 0, 0) if is_light else (255, 255, 255) + # Keep Codex's 4% user-message blend. The composer spans the whole width, + # 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)) + + +def _codex_surface_style(background: tuple[int, int, int] | None) -> str: + blended = _codex_surface_rgb(background, composer=True) + if blended is None: + return "" + return f"bg:#{blended[0]:02x}{blended[1]:02x}{blended[2]:02x}" + + +def _terminal_clear_sequence() -> str: + """Reset the inline viewport and purge terminal scrollback, like Codex ``/clear``.""" + return "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H" + + +class InterruptPending(Exception): + """interrupt chunk 信号:render_stream 抛出,InteractionLoop 捕获后弹确认。""" + + def __init__(self, interrupt_info: Any) -> None: + super().__init__("interrupt pending") + self.interrupt_info = interrupt_info + + +class _NullRenderer: + """默认 no-op renderer(render_stream 不传 renderer 时用)。""" + + async def on_text(self, full_text: str) -> None: + pass + + async def on_thinking(self, full_thinking: str) -> None: + pass + + async def on_tool_call( + self, + tool_name: str, + status: str, + args: Any = None, + call_id: str = "", + ) -> None: + pass + + async def on_usage(self, usage: dict) -> None: + pass + + async def on_error(self, message: str) -> None: + pass + + async def finalize(self) -> None: + pass + + +async def render_stream( + runner, + input_data: dict, + *, + renderer=None, +) -> tuple[str, Optional[dict]]: + """消费 runner.stream,分派 chunk 到 renderer,返回 (cleaned_response, usage)。 + + - text/thinking → 累计全文,调 on_text/on_thinking + - tool_call → 按调用身份和参数快照去重,调 on_tool_call + - final/responses_output → 只取 usage(extract_stream_delta),不 append output + - interrupt → 抛 InterruptPending + - error → 调 on_error + - 流空且未中断 → fallback runner.invoke + 流结束调 renderer.finalize()。 + """ + r = renderer if renderer is not None else _NullRenderer() + full_response = "" + full_thinking = "" + usage: Optional[dict] = None + tool_event_keys: set[tuple[str, str, str, str]] = set() + stream_failed = False + + # text/thinking/tool_call/terminal 都算有内容,避免 tool-only turn 误触发 fallback + saw_content = False + try: + async for chunk in runner.stream(input_data): + chunk_type = str(chunk.get("type") or "text") + + if chunk_type == "thinking": + delta = chunk.get("delta") or "" + if delta: + full_thinking += str(delta) + saw_content = True + await r.on_thinking(full_thinking) + continue + + if chunk_type == "interrupt": + raise InterruptPending(chunk.get("interrupt_info")) + + if chunk_type == "tool_call": + tool_name = str(chunk.get("tool_name") or chunk.get("name") or "") + status = str(chunk.get("status") or "running").lower() + args = chunk.get("tool_args") or chunk.get("arguments") or {} + # 只过滤完全相同的工具事件。参数流会对同一个 call_id 连续给出更完整 + # 的快照,必须继续透传给 renderer 覆盖;调用和结果也必须使用不同键。 + call_id = str(chunk.get("call_id") or "") + dedup_key = ( + "call", + call_id or tool_name, + status, + _compact_json(args), + ) + if dedup_key in tool_event_keys: + continue + tool_event_keys.add(dedup_key) + saw_content = True + await r.on_tool_call(tool_name, status, args, call_id=call_id) + continue + + if chunk_type == "tool_result": + # responses 路径的 function_call_output。结果和调用分开去重,否则 + # 同一个 call_id 的 running 事件会把最终结果错误吞掉。 + tool_name = str(chunk.get("tool_name") or chunk.get("name") or "") + call_id = str(chunk.get("call_id") or "") + output = chunk.get("tool_output") + dedup_key = ( + "result", + call_id or tool_name, + "result", + _compact_json(output), + ) + if dedup_key in tool_event_keys: + continue + tool_event_keys.add(dedup_key) + saw_content = True + await r.on_tool_call(tool_name, "result", output, call_id=call_id) + continue + + if chunk_type == "error": + await r.on_error(str(chunk.get("message") or "未知错误")) + saw_content = True + continue + + delta, chunk_usage, is_terminal = extract_stream_delta(chunk) + if is_terminal: + if chunk_usage: + usage = chunk_usage + await r.on_usage(chunk_usage) + saw_content = True + continue + if delta: + full_response += delta + saw_content = True + await r.on_text(full_response) + except InterruptPending: + raise # finalize 由 finally 统一处理 + except Exception as exc: + # HTTP/transport 错误(raise_for_status 等)→ on_error,不 crash TUI。 + # 请求可能已在服务端执行,不能再 fallback invoke,否则工具副作用会执行两次。 + stream_failed = True + await r.on_error(str(exc) or exc.__class__.__name__) + finally: + await r.finalize() + + if not full_response and not saw_content and not stream_failed: + # 流完全空且未中断 → fallback invoke;失败显式 on_error,不静默吞。 + try: + result = await runner.invoke(input_data) + full_response = str(result.get("output") or "") + if full_response: + await r.on_text(full_response) + if isinstance(result.get("usage"), dict): + usage = result["usage"] + await r.on_usage(usage) + except Exception as exc: + await r.on_error(str(exc) or exc.__class__.__name__) + + return clean_response(full_response), usage + + +@dataclass +class TranscriptEntry: + role: str + content: str = "" + status: str = "" + usage: Optional[dict[str, Any]] = None + thinking: str = "" + + +@dataclass +class QueuedInput: + text: str + entry: TranscriptEntry + + +class SlashCommandCompleter(Completer): + """Slash command completer that works with `/c` style prefixes. + + runner 可选:提供后 `/model ` 前缀补全 runner.available_models 的模型 id。 + """ + + _COMMANDS = { + "/new": "start a new session", + "/clear": "clear transcript", + "/session": "show session id", + "/model": "show/switch model", + "/tools": "toggle tool details", + "/help": "show commands", + } + + def __init__(self, runner=None) -> None: + self.runner = runner + + def get_completions(self, document, complete_event): + from prompt_toolkit.completion import Completion + + text = document.text_before_cursor + prefix = text.strip() + if not prefix.startswith("/"): + return + # /model → 补全可选模型 id + if prefix.startswith("/model ") and self.runner is not None: + 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 "") + return + for command, meta in self._COMMANDS.items(): + if command.startswith(prefix): + yield Completion(command, start_position=-len(prefix), display_meta=meta) + + +class RichLiveRenderer: + """Renderer adapter used by `render_stream` inside the inline TUI。 + + 文本和工具事件按到达顺序保存在 ``_ordered_entries``。同一个 call_id 的 + running/result 更新原位置,工具后的新文本进入新的 assistant 段,因此落定 + 后不会把已完成工具统一搬到最终回复下面。 + """ + + def __init__( + self, + loop: "InteractionLoop" | None = None, + assistant_entry: TranscriptEntry | None = None, + *, + show_thinking: bool = False, + ) -> None: + self.loop = loop + self.assistant_entry = assistant_entry + self.show_thinking = show_thinking + self._tool_entries: list[TranscriptEntry] = [] + self._ordered_entries: list[TranscriptEntry] = [] + self._tool_entries_by_key: dict[str, TranscriptEntry] = {} + self._active_text_entry: TranscriptEntry | None = None + self._last_full_text = "" + + def _compose_streaming(self) -> str: + parts: list[str] = [] + for entry in self._ordered_entries: + if entry.role == "assistant": + if entry.content: + parts.append(entry.content) + continue + # streaming 期 tool 就地显示;完成态仍更新同一行,不改变事件位置。 + name = (entry.content or "").split("\n", 1)[0] + parts.append(f"• {name}") + return "\n".join(parts) + + async def on_text(self, full_text: str) -> None: + if self.loop is not None and self.assistant_entry is not 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):] + else: + # A final/snapshot event can replace the cumulative text. Preserve + # ordering when possible; without tools it is safe to replace the + # sole assistant segment directly. + delta = full_text + if not self._tool_entries: + self._ordered_entries.clear() + self._active_text_entry = None + if delta: + if self._active_text_entry is None: + self._active_text_entry = TranscriptEntry( + role="assistant", + content="", + status="streaming", + ) + self._ordered_entries.append(self._active_text_entry) + self._active_text_entry.content += delta + self._last_full_text = full_text + self.loop._set_streaming(self._compose_streaming()) + + async def on_thinking(self, full_thinking: str) -> None: + if self.loop is not None and self.assistant_entry is not 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: + if self.loop is None: + return + content = f"{tool_name} [{status}]" + if args: + content = f"{content}\n{_compact_json(args)}" + entry = TranscriptEntry(role="tool", content=content, status=status) + # 同一调用按 call_id 覆盖(running → result 状态变化);无 call_id 回退按 name 覆盖。 + # 不同 call_id(同名多次调用)各自追加,不互相覆盖。 + merge_key = call_id or tool_name + existing = self._tool_entries_by_key.get(merge_key) + if existing is None: + self._tool_entries.append(entry) + self._ordered_entries.append(entry) + self._tool_entries_by_key[merge_key] = entry + # The next text delta belongs after this tool event. + self._active_text_entry = None + else: + existing.content = entry.content + existing.status = entry.status + entry = existing + entry._tool_call_id = merge_key # type: ignore[attr-defined] + # tool 行立即进动态区(就地在文本流里显示),不中途落定避免时序错乱。 + self.loop._set_streaming(self._compose_streaming()) + + async def on_usage(self, usage: dict) -> None: + if self.loop is not None and self.assistant_entry is not None: + self.assistant_entry.usage = usage + self.loop._last_usage = usage + + async def on_error(self, message: str) -> None: + if self.loop is not None: + # error 立即落定(错误不属于 assistant 文本流,单列醒目)。 + await self.loop._commit_entry(TranscriptEntry(role="error", content=message)) + + async def finalize(self) -> None: + # 落定由 InteractionLoop._run_turn_async 统一处理,这里只清动态区。 + if self.loop is not None: + self.loop._clear_streaming() + + 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):] + if delta: + if self._active_text_entry is None: + self._active_text_entry = TranscriptEntry(role="assistant") + self._ordered_entries.append(self._active_text_entry) + self._active_text_entry.content += delta + elif response and not self._tool_entries: + if self._ordered_entries: + self._ordered_entries[0].content = response + else: + self._ordered_entries.append(TranscriptEntry(role="assistant", content=response)) + + assistants = [entry for entry in self._ordered_entries if entry.role == "assistant"] + if self.assistant_entry is not None and self.assistant_entry.thinking: + if not assistants: + thinking_entry = TranscriptEntry(role="assistant") + self._ordered_entries.insert(0, thinking_entry) + assistants = [thinking_entry] + assistants[0].thinking = self.assistant_entry.thinking + if self.assistant_entry is not None and self.assistant_entry.usage and assistants: + assistants[-1].usage = self.assistant_entry.usage + + for entry in assistants: + entry.status = "" + return [ + entry + for entry in self._ordered_entries + if entry.role != "assistant" or entry.content or entry.thinking + ] + + +class InteractionLoop: + """Inline Codex-style prompt_toolkit interaction loop.""" + + def __init__( + self, + runner, + *, + show_thinking: bool = False, + project_dir: str = ".", + no_alt_screen: bool = False, + ) -> None: + self.runner = runner + self.show_thinking = show_thinking + self.project_dir = Path(project_dir).resolve() + self._no_alt_screen = no_alt_screen + self.session_id = str(getattr(runner, "session_id", None) or uuid.uuid4().hex[:8]) + if getattr(runner, "session_id", None) is None: + runner.session_id = self.session_id + self.history: list[dict[str, str]] = [] + self._model_name = _resolve_model_name(runner) + self._queued_inputs: list[QueuedInput] = [] + self._active_task: Any = None + self._status_refresh_task: Any = None + self._turn_started_at: float | None = None + self._pending_interrupt: tuple[InterruptPending, dict[str, Any]] | None = None + self._app = None + self._input_buffer = None + self._entries: list[TranscriptEntry] = [] + self._streaming_entry: TranscriptEntry | None = None + self._transcript_ansi = "" + self._transcript_window = None + self._user_scroll = 0 # 用户手动滚动位置(pin bottom 时忽略) + 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._last_usage: dict[str, Any] | None = None + self._showed_help = False # 首次运行显示帮助列表 + self._welcome_ansi: str | None = None + self._emitted_entry_ids: set[int] = set() + self._show_tool_details = False + # 交互式模型选择器状态 + self._model_picker_active = False + self._model_picker_index = 0 + self._model_picker_models: list[dict[str, Any]] = [] + + def run(self) -> None: + asyncio.run(self.run_async()) + + async def run_async(self) -> None: + app = self._build_application() + self._print_initial_history(app) + await app.run_async() + + def _print_initial_history(self, app) -> None: + """Write the startup card and any preloaded entries once before live rendering.""" + from prompt_toolkit.formatted_text import ANSI + + chunks = [self._welcome_history_ansi()] + for entry in self._entries: + chunks.append(self._history_entry_ansi(entry)) + self._emitted_entry_ids.add(id(entry)) + app.print_text(ANSI("\n".join(chunks))) + + def _welcome_history_ansi(self) -> str: + if self._welcome_ansi is None: + self._welcome_ansi = _welcome_block( + self.session_id, + self._current_model_name(), + self.project_dir, + show_help=True, + ) + self._showed_help = True + return self._welcome_ansi + + def _history_entry_ansi(self, entry: TranscriptEntry) -> str: + key = id(entry) + ansi = self._entry_ansi_cache.get(key) + if ansi is None: + ansi = _render_entry_ansi( + entry, + show_thinking=self.show_thinking, + show_tool_details=self._show_tool_details, + ) + self._entry_ansi_cache[key] = ansi + return ansi.rstrip("\n") + + async def _emit_history_entry(self, entry: TranscriptEntry) -> None: + """Commit one history cell above the live prompt, matching Codex scrollback.""" + from prompt_toolkit.application import run_in_terminal + from prompt_toolkit.formatted_text import ANSI + + if id(entry) in self._emitted_entry_ids: + return + self._emitted_entry_ids.add(id(entry)) + if self._app is not None and self._app.is_running: + await run_in_terminal( + lambda: self._app.print_text(ANSI(self._history_entry_ansi(entry) + "\n")) + ) + + def _build_application(self): + from prompt_toolkit.application import Application + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.cursor_shapes import CursorShape + from prompt_toolkit.filters import Condition + from prompt_toolkit.history import InMemoryHistory + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.layout import Layout + from prompt_toolkit.layout.containers import ( + ConditionalContainer, + Float, + FloatContainer, + HSplit, + VSplit, + Window, + ) + from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl + from prompt_toolkit.layout.dimension import Dimension + from prompt_toolkit.layout.menus import CompletionsMenu + from prompt_toolkit.layout.processors import AfterInput, ConditionalProcessor + from prompt_toolkit.styles import Style + + self._input_buffer = Buffer( + completer=SlashCommandCompleter(runner=self.runner), + complete_while_typing=True, + multiline=True, + history=InMemoryHistory(), + ) + + # Codex uses an inline, content-first viewport: short transcripts keep + # the composer directly below the content and a filler absorbs the rest + # of the terminal. Long transcripts shrink to the available viewport. + self._transcript_height = lambda: Dimension( + min=1, + preferred=self._transcript_line_count(), + max=self._transcript_line_count(), + ) + self._transcript_window = Window( + FormattedTextControl( + self._transcript_fragments, + get_cursor_position=self._transcript_cursor, + show_cursor=False, + ), + height=self._transcript_height, + wrap_lines=True, + allow_scroll_beyond_bottom=True, + style="class:transcript", + ) + self._transcript_window_left = Window( + width=2, + height=self._transcript_height, + char=" ", + style="class:transcript", + ) + transcript_row = VSplit([self._transcript_window_left, self._transcript_window]) + + # Codex composer: a three-row input band, with the prompt vertically + # centered and a muted placeholder while the buffer is empty. + prompt_window = Window( + FormattedTextControl(lambda: [("class:prompt", "› ")]), + width=2, + dont_extend_width=True, + ) + self._input_height = lambda: Dimension( + min=1, + preferred=self._input_display_height(), + max=max(1, self._input_display_height()), + ) + placeholder = ConditionalProcessor( + processor=AfterInput( + "Ask KsADK to do anything", + style="class:input-placeholder", + ), + filter=Condition(lambda: not self._input_buffer.text), + ) + self._input_window = Window( + BufferControl( + buffer=self._input_buffer, + input_processors=[placeholder], + ), + height=self._input_height, + wrap_lines=True, + style="class:input", + ) + input_row = VSplit( + [prompt_window, self._input_window], + style="class:input-frame", + ) + composer = HSplit( + [ + Window(height=1, char=" ", style="class:input-frame"), + input_row, + Window(height=1, char=" ", style="class:input-frame"), + ], + style="class:input-frame", + ) + + self._status_condition = Condition( + lambda: self._streaming_entry is not None or self._pending_interrupt is not None + ) + self._status_window = Window( + FormattedTextControl(self._status_fragments), + height=1, + style="class:status", + ) + status_block = ConditionalContainer( + HSplit( + [ + Window(height=1), + self._status_window, + Window(height=1), + ] + ), + filter=self._status_condition, + ) + + self._footer_window = Window( + FormattedTextControl(self._footer_fragments), + height=1, + style="class:footer", + ) + + self._picker_condition = Condition(lambda: self._model_picker_active) + picker_reserve = ConditionalContainer( + Window( + height=lambda: Dimension( + preferred=5 + min(12, max(3, len(self._model_picker_models))) + ), + char=" ", + style="class:app", + ), + filter=self._picker_condition, + ) + + body = HSplit( + [ + transcript_row, + status_block, + Window(height=1), + picker_reserve, + composer, + self._footer_window, + Window(char=" ", style="class:app"), + ], + style="class:app", + ) + # Codex-style model selection surface: unframed title/subtitle, numbered + # rows, and a short confirmation hint above the composer. + picker_width = Dimension(min=32, preferred=68, max=88) + self._picker_window = Window( + FormattedTextControl( + self._model_picker_fragments, + get_cursor_position=self._picker_cursor, + show_cursor=False, + ), + width=picker_width, + height=lambda: Dimension(preferred=min(12, max(3, len(self._model_picker_models)))), + wrap_lines=False, + allow_scroll_beyond_bottom=True, + style="class:model-picker", + ) + picker_body = HSplit( + [ + Window( + FormattedTextControl(self._model_picker_header_fragments), + width=picker_width, + height=3, + style="class:model-picker-header", + ), + self._picker_window, + Window( + FormattedTextControl(self._model_picker_footer_fragments), + width=picker_width, + height=2, + style="class:model-picker-footer", + ), + ], + style="class:model-picker", + ) + picker_float = Float( + # Sit above composer (3 rows), footer (1), and trailing app row (1). + bottom=5, + left=2, + content=ConditionalContainer( + picker_body, + filter=self._picker_condition, + ), + ) + root = FloatContainer( + content=body, + floats=[ + Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=6)), + picker_float, + ], + ) + bindings = KeyBindings() + + @bindings.add("enter") + def _submit(event) -> None: + text = self._input_buffer.text + self._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") + + @bindings.add("c-c") + def _interrupt_or_exit(event) -> None: + if self._has_active_turn(): + self._active_task.cancel() + else: + event.app.exit() + + @bindings.add("escape", filter=self._picker_condition) + def _picker_cancel(event) -> None: + self._close_model_picker() + event.app.invalidate() + + # 注:ESC 取消流式因 prompt_toolkit 的 escape,enter 序列冲突不可靠, + # 流式取消用 c-c(_interrupt_or_exit 已处理)。 + + @bindings.add("c-d") + def _exit_on_empty(event) -> None: + if not self._input_buffer.text: + event.app.exit() + + @bindings.add("pageup") + def _scroll_up(event) -> None: + self._scroll_transcript(-10) + event.app.invalidate() + + @bindings.add("pagedown") + def _scroll_down(event) -> None: + self._scroll_transcript(10) + event.app.invalidate() + + @bindings.add("c-u") + def _scroll_half_up(event) -> None: + self._scroll_transcript(-12) + event.app.invalidate() + + @bindings.add("c-f") + def _scroll_half_down(event) -> None: + self._scroll_transcript(12) + event.app.invalidate() + + composer_arrow_filter = Condition(lambda: not self._model_picker_active) + + @bindings.add("up", filter=composer_arrow_filter) + def _composer_up(event) -> None: + # prompt_toolkit auto_up implements the desired priority: + # completion popup -> multiline cursor -> submitted input history. + event.current_buffer.auto_up() + + @bindings.add("down", filter=composer_arrow_filter) + def _composer_down(event) -> None: + event.current_buffer.auto_down() + + # 模型选择器键:仅 picker 激活时生效 + @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._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._scroll_picker_to_selected() + event.app.invalidate() + + @bindings.add("enter", filter=self._picker_condition) + 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 "") + if mid: + self._apply_model_switch(mid) + self._close_model_picker() + self._ack(f"已切换到 model: {mid}(下一轮请求生效,不持久化)") + return + self._close_model_picker() + event.app.invalidate() + + layout = Layout(root) + layout.focus(self._input_window) + composer_surface = _codex_surface_style(_terminal_background()) + app = Application( + layout=layout, + key_bindings=bindings, + full_screen=False, + mouse_support=False, + min_redraw_interval=0.05, + cursor=CursorShape.BLINKING_BEAM, + style=Style.from_dict( + { + "app": "", + "transcript": "", + "prompt": f"bold {composer_surface}".strip(), + "input": composer_surface, + "input-frame": composer_surface, + "input-placeholder": f"dim {composer_surface}".strip(), + "footer": "ansigray", + "footer-warn": "ansired bold", + "status": "ansigray", + "status-bullet": "ansiwhite bold", + "system": "ansigray", + "welcome-border": "ansigray", + "model-picker": "", + "model-picker-header": "", + "model-picker-title": "bold", + "model-picker-subtitle": "ansigray", + "model-picker-item": "", + "model-picker-selected": "bold", + "model-picker-current": "bold", + "model-picker-footer": "ansigray", + } + ), + ) + self._app = app + self._refresh_transcript() + return app + + # ---- 输入提交 / 轮次 ---- + + def _submit_text(self, user_input: str) -> None: + text = user_input.strip() + if not text: + return + + if self._pending_interrupt is not None: + self._handle_interrupt_answer(text) + return + + action = self._handle_command(text) + if action == "quit": + self._exit_app() + return + if action != "send": + return + + if self._has_active_turn(): + # streaming 时输入 → 排队下一轮(user entry 标记 queued 后落定) + queued_entry = TranscriptEntry(role="user", content=text, status="queued") + self._queued_inputs.append(QueuedInput(text=text, entry=queued_entry)) + self._create_background_task(self._commit_entry(queued_entry)) + return + + self._start_turn(text) + + def _start_turn( + self, + user_input: str, + *, + user_entry: TranscriptEntry | None = None, + input_data: dict[str, Any] | None = None, + is_resume: bool = False, + ) -> None: + self._turn_started_at = time.monotonic() + + async def _run(): + if not is_resume: + if user_entry is not None: + user_entry.status = "" + # 队列的 user_entry 在 _submit_text 排队时已落定,不重复 commit(否则重复显示) + if user_entry not in self._entries: + await self._commit_entry(user_entry) + else: + self._refresh_transcript() + else: + await self._commit_entry(TranscriptEntry(role="user", content=user_input)) + self.history.append({"role": "user", "content": user_input}) + elif user_entry is not None: + user_entry.status = "" + + turn_input = input_data or self._build_input_data(user_input) + assistant_entry = TranscriptEntry(role="assistant", content="", status="streaming") + self._streaming_entry = assistant_entry + self._refresh_transcript() + await self._run_turn_async(turn_input, assistant_entry, is_resume=is_resume) + + self._create_background_task(_run()) + self._ensure_status_refresh() + + async def _run_turn_async( + self, + input_data: dict[str, Any], + assistant_entry: TranscriptEntry, + *, + is_resume: bool = False, + ) -> None: + renderer = RichLiveRenderer( + self, + assistant_entry, + show_thinking=self.show_thinking, + ) + try: + response, usage = await render_stream(self.runner, input_data, renderer=renderer) + except InterruptPending as exc: + self._clear_streaming() + assistant_entry.status = "" + # 中断时仍按原始事件顺序保留已产生的文本和工具调用。 + 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 暂不支持审批续跑,已取消")) + self._clear_current_task() + self._drain_queue() + else: + self._handle_interrupt(exc, input_data) + return + except asyncio.CancelledError: + # 取消流式:保留已产生内容,并维持文本/工具的原始到达顺序。 + self._clear_streaming() + 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="已取消(保留已产生内容)")) + self._clear_current_task() + self._drain_queue() + return + finally: + self._clear_current_task() + + assistant_entry.status = "" + assistant_entry.content = response + if usage: + assistant_entry.usage = usage + self._last_usage = usage + self._clear_streaming() + if response or assistant_entry.thinking: + self.history.append({"role": "model", "content": response}) + for entry in renderer.final_entries(response): + await self._commit_entry(entry) + self._drain_queue() + + def _handle_interrupt(self, exc: InterruptPending, input_data: dict[str, Any]) -> None: + self._pending_interrupt = (exc, input_data) + info = exc.interrupt_info or {} + tool_name = str(info.get("name") or info.get("server_label") or "敏感操作") + + async def _prompt(): + await self._commit_entry( + TranscriptEntry(role="system", content=f"确认执行 {tool_name}? 输入 y 确认,其他内容取消") + ) + + self._create_background_task(_prompt()) + + def _handle_interrupt_answer(self, user_input: str) -> None: + pending = self._pending_interrupt + self._pending_interrupt = None + if pending is None: + return + _exc, input_data = pending + if user_input.lower() in {"y", "yes"}: + + async def _resume(): + await self._commit_entry(TranscriptEntry(role="system", content="已确认,尝试续跑...")) + self._start_turn( + "", + input_data={**input_data, "resume": True}, + is_resume=True, + ) + + self._create_background_task(_resume()) + else: + + async def _cancel(): + await self._commit_entry(TranscriptEntry(role="system", content="已取消")) + self._drain_queue() + + 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: + return + item = self._queued_inputs.pop(0) + self._start_turn(item.text, user_entry=item.entry) + + def _build_input_data(self, user_input: str) -> dict[str, Any]: + data: dict[str, Any] = { + "input": user_input, + "session_id": self.session_id, + "history": list(self.history), + } + if str(getattr(self.runner, "api_format", "") or "").lower() == "responses": + data["responses_conversation"] = True + return data + + def _has_active_turn(self) -> bool: + return self._active_task is not None and not self._active_task.done() + + def _clear_current_task(self) -> None: + current = asyncio.current_task() + if self._active_task is current: + self._active_task = None + self._turn_started_at = None + + def _create_background_task(self, coro) -> None: + if self._app is not None: + self._active_task = self._app.create_background_task(coro) + else: + self._active_task = asyncio.create_task(coro) + + def _ensure_status_refresh(self) -> None: + """Refresh elapsed-time status only while a turn is actively running.""" + if self._app is None or not self._app.is_running: + return + if self._status_refresh_task is None or self._status_refresh_task.done(): + self._status_refresh_task = self._app.create_background_task( + self._status_refresh_loop() + ) + + async def _status_refresh_loop(self) -> None: + current_task = asyncio.current_task() + try: + while self._has_active_turn(): + await asyncio.sleep(0.5) + if self._app is not None and self._app.is_running: + self._app.invalidate() + finally: + if self._status_refresh_task is current_task: + self._status_refresh_task = None + + def _ack(self, content: str) -> None: + """命令的系统提示落定到 transcript 列表。""" + self._commit_entry_sync(TranscriptEntry(role="system", content=content)) + + def _commit_entry_sync(self, entry: TranscriptEntry) -> None: + """Record a history cell and schedule its one-time scrollback emission.""" + self._entries.append(entry) + if self._app is not None and self._app.is_running: + self._app.create_background_task(self._emit_history_entry(entry)) + self._refresh_transcript() + + async def _commit_entry(self, entry: TranscriptEntry) -> None: + """Record and emit a history cell before resuming the live prompt.""" + self._entries.append(entry) + await self._emit_history_entry(entry) + self._refresh_transcript() + + def _handle_command(self, user_input: str) -> str: + """分派命令。返回: 'quit'=退出, 'send'=发给 agent, 'handled'=命令已处理不发送。""" + lower = user_input.lower() + if lower in {"exit", "quit", "退出"}: + return "quit" + if user_input == "/new": + if self._has_active_turn(): + self._ack("回复进行中,请先 Ctrl-C 取消再 /new") + return "handled" + self.session_id = uuid.uuid4().hex[:8] + self.runner.session_id = self.session_id + self.history = [] + self._queued_inputs = [] + self._pending_interrupt = None + self._entries = [] + self._entry_ansi_cache.clear() + self._welcome_ansi = None + self._history_pager_active = False + self._reset_scroll() + self._clear_streaming() + self._ack(f"新会话: {self.session_id}") + elif user_input == "/clear": + if self._has_active_turn(): + self._ack("回复进行中,请先 Ctrl-C 取消再 /clear") + return "handled" + self._entries = [] + self._entry_ansi_cache.clear() + self._history_pager_active = False + self._reset_scroll() + self._clear_streaming() + self._refresh_transcript() + if self._app is not None and self._app.is_running: + self._create_background_task(self._clear_terminal_scrollback()) + elif user_input == "/session": + self._ack(f"session: {self.session_id}") + elif user_input == "/model" or user_input.startswith("/model "): + self._handle_model_command(user_input) + elif user_input == "/tools": + self._show_tool_details = not self._show_tool_details + self._entry_ansi_cache.clear() + self._ack(f"工具详情已{'展开' if self._show_tool_details else '折叠'}") + elif user_input in {"?", "/help", "/?"}: + self._ack(_help_text()) + elif user_input.startswith("/"): + self._ack(f"未知命令: {user_input}(可用: /new /clear /session /model /tools ? exit)") + else: + return "send" # 普通输入,发给 agent + return "handled" + + def _handle_model_command(self, user_input: str) -> None: + """/model:弹出交互式选择器(↑↓ Enter Esc);/model :直接切换(runnable 级,下轮生效)。""" + arg = user_input[len("/model"):].strip() + if arg: + # 直接切换:改 runner.model,下轮请求带新 model + self._apply_model_switch(arg) + self._ack(f"已切换到 model: {arg}(下一轮请求生效,不持久化)") + return + # 无参:有可选列表 → 弹交互式选择器;否则文本提示 + available = getattr(self.runner, "available_models", None) or [] + if available: + self._open_model_picker(available) + else: + self._ack( + f"current model: {self._current_model_name()}\n" + "(无可选模型列表;回复一次后显示真实名)\n" + "切换: /model " + ) + + def _apply_model_switch(self, model_id: str) -> None: + """切换模型:改 runner.model + 从 available_models 更新 model_metadata。 + + 更新 metadata 让 footer 的 context window 用新模型的值;清 _observed_model + 避免旧回包观察名覆盖。runnable 级,下轮请求带新 model。 + """ + 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) + if matched: + self.runner.model_metadata = matched + # 清旧观察名,让 _current_model_name 用新 runner.model + if getattr(self.runner, "_observed_model", None): + self.runner._observed_model = None + + def _open_model_picker(self, models: list[dict[str, Any]]) -> None: + self._model_picker_models = list(models) + current = self._current_model_name() + self._model_picker_index = 0 + for i, m in enumerate(self._model_picker_models): + mid = str(m.get("id") or m.get("name") or "") + if mid == current: + self._model_picker_index = i + break + self._model_picker_active = True + self._scroll_picker_to_selected() + if self._app is not None: + self._app.invalidate() + + def _close_model_picker(self) -> None: + self._model_picker_active = False + self._model_picker_models = [] + if self._picker_window is not None: + self._picker_window.vertical_scroll = 0 + if self._app is not None: + self._app.invalidate() + + def _scroll_picker_to_selected(self) -> None: + """让选中项始终在 picker 可见区内(列表超长时跟随滚动)。 + + picker 窗口封顶 12 行(见 _build_application 的 height lambda)。render_info + 首帧可能为 None,按 12 估算可见高度;渲染后 Window 会用设的 vertical_scroll。 + """ + w = self._picker_window + if w is None or not self._model_picker_models: + return + ri = getattr(w, "render_info", None) + visible = int(getattr(ri, "window_height", 0) or 0) or 12 + # 选中项偏上 1/3 处可见,避免紧贴边缘 + target = max(0, self._model_picker_index - max(1, visible // 3)) + max_scroll = max(0, len(self._model_picker_models) - visible) + w.vertical_scroll = min(target, max_scroll) + + def _picker_cursor(self): + """cursor 跟随选中项行号,驱动 Window 滚动让选中项可见。""" + from prompt_toolkit.layout.screen import Point + + return Point(x=0, y=self._model_picker_index) + + def _model_picker_header_fragments(self): + return [ + ("class:model-picker-title", "Select Model and Effort\n"), + ( + "class:model-picker-subtitle", + "Access legacy models by running codex -m or in your config.toml\n\n", + ), + ] + + def _model_picker_footer_fragments(self): + return [("class:model-picker-footer", "\nPress enter to confirm or esc to go back")] + + def _model_picker_fragments(self): + """Codex-style numbered list with a selection chevron and current marker.""" + from prompt_toolkit.formatted_text import FormattedText + + current = self._current_model_name() + 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) + marker = "›" if is_selected else " " + line = f"{marker} {i + 1}. {mid}" + if is_cur_model: + line += " (current)" + style = "class:model-picker-selected" if is_selected else "class:model-picker-item" + frags.append((style, f"{line}\n")) + return FormattedText(frags) if frags else FormattedText([("", "(no models)")]) + + # ---- streaming / transcript 渲染 ---- + + def _set_streaming(self, full_text: str) -> None: + """streaming 文本更新到当前 streaming entry(动态区 = transcript 末尾的 streaming entry)。""" + if self._streaming_entry is not None: + self._streaming_entry.content = full_text + self._streaming_entry.status = "streaming" + self._refresh_transcript() + + def _clear_streaming(self) -> None: + self._streaming_entry = None + self._refresh_transcript() + + def _refresh_transcript(self) -> None: + """Render only the mutable live tail; completed history lives in scrollback.""" + parts: list[str] = [] + if self._history_pager_active: + parts = [ + self._history_entry_ansi(entry).rstrip("\n") + for entry in self._entries + if self._history_entry_ansi(entry).strip() + ] + elif self._streaming_entry is not None and ( + self._streaming_entry.content or self._streaming_entry.status + ): + ansi = _render_entry_ansi(self._streaming_entry, show_thinking=self.show_thinking) + if ansi.strip(): + parts.append(ansi.rstrip("\n")) + self._transcript_ansi = ("\n\n".join(parts) + "\n") if parts else "" + # follow 快照(对标 Codex is_scrolled_to_bottom):若当前在底才跟新内容到底, + # 用户翻走(vertical_scroll < max_scroll)则保持位置不覆盖。这样鼠标滚轮/键盘 + # 翻后不被 streaming 刷新拽回底部。 + if self._transcript_window is not None: + # prompt_toolkit 的内建鼠标滚轮直接修改 Window.vertical_scroll,不会经过 + # _scroll_transcript。先用上一帧记录识别这种外部滚动,再决定是否跟底。 + current_vs = int(getattr(self._transcript_window, "vertical_scroll", 0) or 0) + if current_vs != self._user_scroll: + self._user_scroll = current_vs + self._pin_to_bottom = current_vs >= max(0, self._last_max_scroll - 1) + max_scroll = self._max_scroll() + if self._pin_to_bottom: + self._transcript_window.vertical_scroll = max_scroll + self._user_scroll = max_scroll + else: + preserved = min(current_vs, max_scroll) + self._transcript_window.vertical_scroll = preserved + self._user_scroll = preserved + self._last_max_scroll = max_scroll + if self._app is not None: + self._app.invalidate() + + def _transcript_fragments(self): + from prompt_toolkit.formatted_text import ANSI + + return ANSI(self._transcript_ansi) + + def _transcript_cursor(self): + """cursor 跟随当前 vertical_scroll(视口顶行)。 + + 这样 prompt_toolkit 的 do_scroll 不钳制(cursor 总在视口顶可见), + 手动设的 vertical_scroll 保留。pin bottom → 在 _refresh_transcript/ + _scroll_transcript 里设 vertical_scroll=max_scroll 跟底;用户翻 → 设 user_scroll。 + (经验证:cursor=末行 时 do_scroll 跟底 OK,但 cursor=中部行时视口不动 → + 用户翻页不生效。改用 cursor 跟随视口顶,手动设 vertical_scroll 控制位置。) + """ + from prompt_toolkit.layout.screen import Point + + vs = int(getattr(self._transcript_window, "vertical_scroll", 0) or 0) + # render_info belongs to the previous frame. During streaming finalize, + # clear, or resize it can have more lines than the current fragments and + # would make prompt_toolkit index past UIContent.get_line(). + line_count = self._transcript_line_count() + return Point(x=0, y=min(vs, line_count - 1)) + + def _transcript_line_count(self) -> int: + """Return the logical line count of the fragments for the next frame.""" + return max(1, self._transcript_ansi.count("\n") + 1) + + def _max_scroll(self) -> int: + """算 max_scroll:显示行数 - window_height。 + + Line count must come from the current transcript. ``render_info`` is a + snapshot of the previous frame and can be stale while streaming content + is replaced or cleared. Window height still comes from the last render, + with a terminal-size fallback before the first frame. + """ + if self._transcript_window is None: + return 0 + ri = getattr(self._transcript_window, "render_info", None) + 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) + + def _reset_scroll(self) -> None: + """/new /clear 时重置滚动状态到 pin bottom 顶部。""" + self._user_scroll = 0 + self._pin_to_bottom = True + self._last_max_scroll = 0 + if self._transcript_window is not None: + self._transcript_window.vertical_scroll = 0 + + def _scroll_transcript(self, delta: int) -> None: + """Scroll the live tail or open the retained-history pager on demand.""" + if self._transcript_window is None: + return + if not self._history_pager_active and self._entries: + if delta >= 0: + return + self._history_pager_active = True + self._pin_to_bottom = True + self._refresh_transcript() + max_scroll = self._max_scroll() + current = max_scroll if self._pin_to_bottom else self._user_scroll + if self._history_pager_active and delta > 0 and current + delta >= max_scroll: + self._history_pager_active = False + self._reset_scroll() + self._refresh_transcript() + return + new_scroll = max(0, min(current + delta, max_scroll)) + self._user_scroll = new_scroll + self._pin_to_bottom = new_scroll >= max_scroll + self._transcript_window.vertical_scroll = new_scroll + if self._app is not None: + self._app.invalidate() + + # ---- footer / 输入框高度 ---- + + def _current_model_name(self) -> str: + """动态取模型名:metadata 到达后(_fetch_tui_model_metadata 挂载)优先用真实名。""" + return _resolve_model_name(self.runner) + + def _status_fragments(self): + if self._pending_interrupt is not None: + return [ + ("class:status-bullet", "• "), + ("class:footer-warn", "Approval required (type y then Enter to confirm)"), + ] + elapsed = 0 + if self._turn_started_at is not None: + elapsed = max(0, int(time.monotonic() - self._turn_started_at)) + queued = f" · {len(self._queued_inputs)} queued" if self._queued_inputs else "" + return [ + ("class:status-bullet", "• "), + ("class:status", f"Working ({elapsed}s · Ctrl-C to interrupt{queued})"), + ] + + def _footer_fragments(self): + parts = [self._current_model_name()] + try: + short = "~/" + str(self.project_dir.relative_to(Path.home())) + except ValueError: + short = str(self.project_dir) + ctx = self._context_percent() + if ctx: + parts.append(ctx.split(" · ", 1)[0]) + parts.append(short) + return [("class:footer", " " + " · ".join(parts) + " ")] + + def _context_percent(self) -> str | None: + """上下文窗口剩余占比,对标 Codex CLI 的 "Context 87% left · 12.3K used · 200K window"。 + + 算法与 codex-rs/tui/src/token_usage.rs 一致: + - tokens_in_context = last_usage.total_tokens(当前上下文总大小) + - effective_window = context_window - BASELINE_TOKENS(12000) + - used = max(0, total_tokens - BASELINE_TOKENS) + - remaining% = (effective_window - used) / effective_window * 100 + BASELINE 折扣避免把 system prompt 等固定基线算进"已用"。 + 返回 "Context {pct}% left · {used}K used · {window}K window"(· 分隔,大写 K)。 + """ + meta = getattr(self.runner, "model_metadata", None) or {} + if not isinstance(meta, dict): + return None + window = meta.get("context_window_tokens") or meta.get("max_input_tokens") + last = self._last_usage or {} + if not isinstance(last, dict): + return None + ctx_usage = last.get("last_usage") or last + if not isinstance(ctx_usage, dict): + return None + tokens_in_context = ctx_usage.get("total_tokens") or ctx_usage.get("input_tokens") + if not window or not tokens_in_context: + return None + try: + window = int(window) + tokens_in_context = int(tokens_in_context) + except (TypeError, ValueError): + return None + if window <= 0: + return None + # 直接 used/window(不去 Codex BASELINE 12000:runtime 的 total_tokens 已含 + # 全部上下文,不需要再假设 12K 基线;BASELINE 会让小用量卡 100%)。 + # int() 不 round,避免 99.99% 进 100%。 + used = max(0, tokens_in_context) + remaining = max(0, window - used) + pct = int(remaining / window * 100) + pct = max(0, min(100, pct)) + return ( + f"Context {pct}% left · {_format_token_count(tokens_in_context)} used" + f" · {_format_token_count(window)} window" + ) + + def _input_display_height(self) -> int: + if self._input_buffer is None: + return 1 + try: + import math + import shutil + + from prompt_toolkit.utils import get_cwidth + + columns = max(20, shutil.get_terminal_size(fallback=(80, 24)).columns - 3) + line_count = 0 + for line in (self._input_buffer.text or "").split("\n"): + line_count += max(1, math.ceil(get_cwidth(line) / columns)) + return max(1, min(3, line_count)) + except Exception: + return max(1, min(3, (self._input_buffer.text or "").count("\n") + 1)) + + def _exit_app(self) -> None: + if self._app is not None: + self._app.exit() + + async def _clear_terminal_scrollback(self) -> None: + if self._app is None or not self._app.is_running: + return + from prompt_toolkit.application import run_in_terminal + + def _write_clear() -> None: + import sys + + sys.stdout.write(_terminal_clear_sequence()) + sys.stdout.flush() + + await run_in_terminal(_write_clear) + + +def _resolve_model_name(runner) -> str: + import os + + # 优先 model_metadata["id"](catalog fetch),其次 _observed_model(流式回包观察到的真实名), + # 其次 runner.model(用户 --model 指定),最后 MODEL_NAME 环境变量。 + meta = getattr(runner, "model_metadata", None) + if isinstance(meta, dict) and meta.get("id"): + 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" + + +def _help_text() -> str: + 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 = "" + try: + short = "~/" + str(project_dir.relative_to(Path.home())) + except ValueError: + short = str(project_dir) + title = f">_ KsADK (v{version})" if version else ">_ KsADK" + inner_lines = [ + title, + "", + f"model: {model_name} /model to change", + f"directory: {short}", + f"session: {session_id}", + ] + # 按终端宽度算框宽,留 transcript 左缩进 2 + 右 margin 2,上限 56(Codex 同款)。 + try: + import shutil + cols = shutil.get_terminal_size(fallback=(80, 24)).columns + except Exception: + cols = 80 + max_inner = max(20, cols - 2 - 2 - 2) # 减去左缩进2 + 两侧│ + 右margin2 + longest = max(len(s) for s in inner_lines) + inner = min(max_inner, max(20, longest)) # 内容能放下且不超终端 + inner_lines = [s[:inner] for s in inner_lines] # 防溢出截断 + + def _pad(s: str) -> str: + # CJK 宽度近似:用 len 简化(圆角框对齐以纯文本宽度为准,rich 不再二次渲染框线) + return s + " " * max(0, inner - len(s)) + + # Middle rows include one padding cell on both sides of the content, so + # the horizontal rules must span ``inner + 2`` cells as well. + outer_inner = inner + 2 + top = f"╭{'─' * outer_inner}╮" + bottom = f"╰{'─' * outer_inner}╯" + mid = [f"│ {_pad(line)} │" for line in inner_lines] + block = [top, *mid, bottom] + + lines = ["", *block, ""] + if show_help: + lines.append("Tip: describe a task, or type /help to see available commands.") + lines.append("") + return "\n".join(lines) + + +def _normalize_markdown(source: str) -> str: + """Normalize common model markdown typos without touching fenced code.""" + import re + + source_lines = str(source).replace("\r\n", "\n").split("\n") + if ( + len(source_lines) >= 2 + and source_lines[0].strip().lower() in {"```md", "```markdown"} + and source_lines[-1].strip() == "```" + ): + source_lines = source_lines[1:-1] + + lines: list[str] = [] + in_fence = False + for raw_line in source_lines: + line = raw_line + stripped = line.lstrip() + if stripped.startswith("```"): + in_fence = not in_fence + lines.append(line) + continue + if not in_fence: + # Models frequently emit ``###1.`` for numbered cards. Treat it as + # a list item, otherwise Rich displays the hashes literally. + line = re.sub(r"^(\s*)#{1,6}\s*(\d+)[.)]\s*", r"\1\2. ", line) + # Common heading/list omissions: ``##标题`` and ``1) item``. + line = re.sub(r"^(\s*)(#{1,6})(\S)", r"\1\2 \3", line) + line = re.sub(r"^(\s*)(\d+)[)]\s*", r"\1\2. ", line) + # Some model responses concatenate numbered emoji cards on one line + # (``...技能2. 📄 ...3. 🌐 ...``). Split only when the marker is + # followed by an icon/CJK lead, avoiding decimal numbers in prose. + lead = r"[\u2600-\u27bf\U0001f000-\U0001faff\u4e00-\u9fff]" + line = re.sub( + rf"(?<=[^\d\s])\s*(?=\d{{1,2}}[.)]?\s+{lead})", + "\n", + line, + ) + line = re.sub( + rf"^(\s*)(\d{{1,2}})\s+(?={lead})", + r"\1\2. ", + line, + flags=re.MULTILINE, + ) + lines.append(line) + return "\n".join(lines) + + +def _render_entry_ansi( + entry: TranscriptEntry, + *, + show_thinking: bool, + show_tool_details: bool = True, +) -> str: + """用 rich 把单条 entry 渲染成带前景色的 ANSI 字符串(去背景,适配浅/深色终端)。""" + try: + from rich.console import Console + from rich.markdown import Markdown + from rich.text import Text + except ImportError: + return _render_entry_plain(entry, show_thinking=show_thinking) + + import io + import shutil + + width = 100 + try: + width = max(60, shutil.get_terminal_size(fallback=(100, 24)).columns - 2) + except Exception: + pass + + console = Console( + color_system="standard", + force_terminal=True, + file=io.StringIO(), + record=True, + width=width, + legacy_windows=False, + ) + body = entry.content or ("..." if entry.role == "assistant" and entry.status else "") + + if entry.role == "system": + if body: + console.print(Text(str(body), style="dim")) + return console.export_text(styles=True).rstrip() + "\n" + + if entry.role == "separator": + # tool 与 assistant 间横线分隔(对标 Codex FinalMessageSeparator) + console.print(Text(str(body or "─" * 40), style="dim")) + return console.export_text(styles=True).rstrip() + "\n" + + if entry.role == "user": + suffix = f" · {entry.status}" if entry.status else "" + surface = _codex_surface_rgb(_terminal_background()) + surface_style = "bold" + if surface is not None: + surface_style += f" on #{surface[0]:02x}{surface[1]:02x}{surface[2]:02x}" + lines = str(body).splitlines() or [""] + for index, line in enumerate(lines): + prefix = "› " if index == 0 else " " + text = Text(f"{prefix}{line}{suffix if index == 0 else ''}", style=surface_style) + text.pad_right(max(0, width - text.cell_len)) + console.print(text, no_wrap=True, overflow="crop") + return console.export_text(styles=True).rstrip() + "\n" + + if entry.role == "assistant": + # streaming 状态由 footer 表达,不在内容里加 "· streaming" 标记 + suffix = f" · {entry.status}" if entry.status and entry.status != "streaming" else "" + if entry.thinking and show_thinking: + console.print(Text("* thinking", style="yellow")) + console.print(Markdown(_normalize_markdown(entry.thinking))) + console.print(Text("• ", style="dim"), end="") + if body: + if entry.status == "streaming": + # streaming 期用纯文本(不 rich Markdown),避免每 token 重渲增长的长内容卡顿。 + # 落定后(status="")才 Markdown 渲染完整格式(表格/代码块等)。 + console.print(Text(str(body))) + else: + console.print(Markdown(_normalize_markdown(f"{body}{suffix}"))) + else: + 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": + # 对标 Codex exec_cell:单 bullet 表状态(running dim/完成绿),文字 + # Running→Ran + 工具名。结果折叠 5 行 head+… +N lines+tail。 + parts = (entry.content or "").split("\n", 1) + tool_name = parts[0].split(" [")[0] if parts else "" + output = parts[1] if len(parts) > 1 else "" + status = entry.status or "" + done = status in {"result", "completed"} + bullet = Text("• ", style="green bold" if done else "dim") + title = "Ran" if done else "Running" + console.print(bullet, end="") + console.print(Text(f"{title} {tool_name}", style="bold" if done else "")) + if output and show_tool_details: + # 尝试 JSON 美化(单行长 JSON → 多行可读),再按显示行数折叠 + 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 + # instead of rendering as one escaped line. + if isinstance(parsed, str): + nested = parsed.strip() + # Some adapters wrap the JSON in a repr-like + # ``content='...' name='...'`` envelope. + if nested.startswith("content="): + import ast as _ast + + quote = nested[len("content="):len("content=") + 1] + if quote in {"'", '"'}: + body_start = len("content=") + 1 + body_end = body_start + while body_end < len(nested): + if nested[body_end] == quote: + backslashes = 0 + cursor = body_end - 1 + while cursor >= body_start and nested[cursor] == "\\": + backslashes += 1 + cursor -= 1 + if backslashes % 2 == 0: + break + body_end += 1 + try: + nested = _ast.literal_eval( + quote + nested[body_start:body_end] + quote + ).strip() + except (SyntaxError, ValueError): + pass + if nested.startswith(("{", "[")): + parsed = _json.loads(nested) + display_output = _json.dumps(parsed, ensure_ascii=False, indent=2) + except Exception: + 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 + from prompt_toolkit.utils import get_cwidth + + def _width(s: str) -> int: + return get_cwidth(s) + + logical_lines = display_output.split("\n") + display_line_count = sum(max(1, -(-_width(ln) // cw)) for ln in logical_lines) + # 逻辑行 > 6 才 head/tail 折叠(避免逻辑行少时 head/tail 重叠重复); + # 逻辑行 ≤ 6 但显示行长(长字符串)→ 不折叠,靠每行截断控制宽度。 + if display_line_count > 8 and len(logical_lines) > 6: + head, tail = logical_lines[:3], logical_lines[-3:] + shown = head + [f"… +{display_line_count-6} lines (PgUp to scroll)"] + tail + else: + shown = logical_lines + for ln in shown: + # 长逻辑行按显示宽度截断(CJK 占 2,避免溢出) + if _width(ln) > cw: + # 逐步截断到 cw 宽度 + cut = cw - 1 + while cut > 0 and _width(ln[:cut]) > cw - 1: + cut -= 1 + ln = ln[:cut] + "…" + console.print(Text(f" └ {ln}", style="dim")) + return console.export_text(styles=True).rstrip() + "\n" + + if entry.role == "error": + console.print(Text(f"! {body}", style="red bold")) + return console.export_text(styles=True).rstrip() + "\n" + + if body: + console.print(Text(str(body))) + return console.export_text(styles=True).rstrip() + "\n" + + +def _render_entry_plain(entry: TranscriptEntry, *, show_thinking: bool) -> str: + """rich 不可用时的纯文本回退。""" + body = entry.content or ("..." if entry.role == "assistant" and entry.status else "") + if entry.thinking and show_thinking: + body = f"Thinking:\n{entry.thinking}\n\n{body}".strip() + suffix = f" · {entry.status}" if entry.status and entry.role in {"user", "assistant"} else "" + if entry.role == "user": + return _prefix_block(f"› {body}{suffix}", continuation=" ") + "\n" + if entry.role == "assistant": + return _prefix_block(f"• {body}{suffix}", continuation=" ") + "\n" + if entry.role == "tool": + return _prefix_block(f"↳ {body}", continuation=" ") + "\n" + if entry.role == "error": + return f"! {body}\n" + return f"{body}\n" + + +def _prefix_block(text: str, *, continuation: str) -> str: + lines = str(text).splitlines() or [""] + rendered = [lines[0]] + rendered.extend(f"{continuation}{line}" if line else "" for line in lines[1:]) + return "\n".join(rendered) + + +def _compact_json(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + except TypeError: + return str(value) + + +def _format_token_count(value: Any) -> str: + try: + number = int(value or 0) + except (TypeError, ValueError): + number = 0 + if number >= 1_000_000: + return f"{number / 1_000_000:.1f}M" + if number >= 1_000: + return f"{number / 1_000:.1f}K" + return str(number) + + +def _strip_ansi_backgrounds(text: str) -> str: + """只剥 ANSI 背景色(40/48),保留前景色——避免浅色终端背景色糊住文字。""" + import re + + def _clean(match: "re.Match[str]") -> str: + params = match.group(1).split(";") + cleaned: list[str] = [] + index = 0 + while index < len(params): + code = params[index] + if code == "40" or code == "49": + index += 1 + continue + if code == "48": + mode = params[index + 1] if index + 1 < len(params) else "" + if mode == "2": + index += 5 + continue + if mode == "5": + index += 3 + continue + cleaned.append(code) + index += 1 + return f"\x1b[{';'.join(cleaned)}m" if cleaned else "" + + 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: + """TUI 入口:被 cmd_invoke._invoke_tui / cmd_run._run_custom 调用。 + + ``no_alt_screen`` 作为兼容参数保留;TUI 现在默认使用 Codex-style inline viewport。 + """ + InteractionLoop( + runner, + show_thinking=show_thinking, + project_dir=project_dir, + no_alt_screen=no_alt_screen, + ).run() diff --git a/ksadk/tui/stream_render.py b/ksadk/tui/stream_render.py new file mode 100644 index 00000000..7b5805aa --- /dev/null +++ b/ksadk/tui/stream_render.py @@ -0,0 +1,68 @@ +"""TUI 流式渲染共享纯函数。 + +从 RemoteRunner.stream 的归一化 chunk 提取 delta/usage/终止信号,格式化 usage 文本。 +被 loop.py(交互 TUI)和 cmd_invoke._invoke_once(-m 单次)共用,保证两路径渲染口径一致。 +""" +from __future__ import annotations + +import re +from typing import Any, Optional + + +def clean_response(text: str) -> str: + """清理 LLM 响应中的内部调试伪影。 + + 只删内部调试残留(Tool Result 标记、python repr 片段、tool_call_id), + 不动正常的 XML/HTML 标签(用户可能需要 输出)。 + """ + text = re.sub(r"\[Tool Result:.*?\]", "", text, flags=re.DOTALL) + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = re.sub(r"name='[^']*'\s*tool_call_id='[^']*'", "", text) + return text.strip() + + +def extract_stream_delta(chunk: dict) -> tuple[str, dict | None, bool]: + """从 RemoteRunner.stream 的 chunk 提取 (delta 文本, usage, 是否终止信号)。 + + - text/thinking 等普通 chunk:取 delta,非终止。 + - final(chat 路径结束,output=完整文本)/ responses_output(responses 路径 + response.completed,output=list):只取 usage,不把 output 当 delta—— + 否则末尾会把完整文本或 list 再 append 一遍(重复/乱码)。 + """ + chunk_type = str(chunk.get("type") or "text") + if chunk_type in {"final", "responses_output"}: + usage = chunk.get("usage") + usage = dict(usage) if isinstance(usage, dict) else None + # terminal chunk 可能带 metadata.last_usage(上下文用量),合并进 usage + # 让 TUI 能显示 context 占比,不丢这条 metadata。 + metadata = chunk.get("metadata") + if isinstance(metadata, dict) and isinstance(metadata.get("last_usage"), dict): + if usage is None: + usage = {} + usage.setdefault("last_usage", dict(metadata["last_usage"])) + return "", (usage or None), True + delta = chunk.get("delta") or "" + if not isinstance(delta, str): + delta = str(delta or "") + return delta, None, False + + +def format_usage(usage: Optional[dict[str, Any]]) -> str: + """把 usage dict 格式成紧凑文本:↑输入 ↓输出 ⌀总计。 + + 兼容 input_tokens/output_tokens(chat 路径)与 prompt_tokens/completion_tokens + (responses 路径)两种字段名。 + """ + if not isinstance(usage, dict) or not usage: + return "" + inp = usage.get("input_tokens") or usage.get("prompt_tokens") + out = usage.get("output_tokens") or usage.get("completion_tokens") + total = usage.get("total_tokens") + parts = [] + if inp: + parts.append(f"↑{inp}") + if out: + parts.append(f"↓{out}") + if total: + parts.append(f"⌀{total}") + return " ".join(parts) diff --git a/ksadk/tui/widgets/__init__.py b/ksadk/tui/widgets/__init__.py deleted file mode 100644 index 9ee6bb23..00000000 --- a/ksadk/tui/widgets/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -TUI Widgets - 消息组件 - -提供各类消息组件,用于 TUI 渲染。 -参考 deepagents-cli 的消息组件设计。 -""" - -from .base import MessageWidget -from .user import UserMessage -from .assistant import AssistantMessage -from .thinking import ThinkingMessage -from .tool_call import ToolCallMessage -from .system import SystemMessage - -__all__ = [ - "MessageWidget", - "UserMessage", - "AssistantMessage", - "ThinkingMessage", - "ToolCallMessage", - "SystemMessage", -] diff --git a/ksadk/tui/widgets/assistant.py b/ksadk/tui/widgets/assistant.py deleted file mode 100644 index ef14a91e..00000000 --- a/ksadk/tui/widgets/assistant.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -AssistantMessage - 助手消息组件 - -支持流式 MarkdownStream 渲染(与 deepagents-cli 一致) -""" - -from textual.containers import Vertical -from textual.widgets import Markdown -from textual.widgets._markdown import MarkdownStream -from typing import Any, Optional - - -class AssistantMessage(Vertical): - """助手消息组件 - - 使用 MarkdownStream 实现流式 Markdown 渲染 - """ - - DEFAULT_CSS = """ - AssistantMessage { - height: auto; - padding: 0 1; - margin: 1 0 0 0; - color: #ffffff; - } - - AssistantMessage Markdown { - padding: 0; - margin: 0; - color: #ffffff; - } - - /* 确保 Markdown 的所有子组件都有正确的颜色 */ - AssistantMessage Markdown * { - color: #ffffff; - } - """ - - def __init__(self, content: str = "", **kwargs: Any) -> None: - super().__init__(**kwargs) - self._content = content - self._markdown: Optional[Markdown] = None - self._stream: Optional[MarkdownStream] = None - - def compose(self): - """构建助手消息布局""" - yield Markdown("", id="assistant-content") - - def on_mount(self) -> None: - """缓存 markdown widget 引用""" - self._markdown = self.query_one("#assistant-content", Markdown) - - def _get_markdown(self) -> Markdown: - """获取 markdown widget""" - if self._markdown is None: - self._markdown = self.query_one("#assistant-content", Markdown) - return self._markdown - - def _ensure_stream(self) -> MarkdownStream: - """确保 stream 已初始化""" - if self._stream is None: - self._stream = Markdown.get_stream(self._get_markdown()) - return self._stream - - async def append_content(self, text: str) -> None: - """追加内容(流式渲染)""" - if not text: - return - self._content += text - stream = self._ensure_stream() - await stream.write(text) - - async def stop_stream(self) -> None: - """停止流式渲染""" - if self._stream is not None: - await self._stream.stop() - self._stream = None - - async def set_content(self, content: str) -> None: - """设置完整内容""" - await self.stop_stream() - self._content = content - if self._markdown: - await self._markdown.update(content) - - @property - def content(self) -> str: - return self._content - diff --git a/ksadk/tui/widgets/base.py b/ksadk/tui/widgets/base.py deleted file mode 100644 index 67ddc726..00000000 --- a/ksadk/tui/widgets/base.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Base Widget - 消息组件基类 -""" - -from textual.widget import Widget -from textual.reactive import reactive -from typing import Any, Optional - - -class MessageWidget(Widget): - """消息组件基类 - - 所有消息组件的抽象基类,提供基础的消息渲染能力。 - """ - - DEFAULT_CSS = """ - MessageWidget { - width: 100%; - padding: 0 1; - margin: 0 0 1 0; - color: #ffffff; - } - """ - - content: reactive[str] = reactive("") - - def __init__( - self, - content: str = "", - *, - name: Optional[str] = None, - id: Optional[str] = None, - classes: Optional[str] = None, - ) -> None: - super().__init__(name=name, id=id, classes=classes) - self.content = content - self._is_streaming = False - - async def append_content(self, delta: str) -> None: - """追加内容(流式渲染) - - Args: - delta: 增量内容 - """ - self._is_streaming = True - self.content += delta - - async def stop_stream(self) -> None: - """停止流式渲染""" - self._is_streaming = False - - def watch_content(self, new_content: str) -> None: - """监听内容变化,触发重新渲染""" - self.refresh() diff --git a/ksadk/tui/widgets/chat_input.py b/ksadk/tui/widgets/chat_input.py deleted file mode 100644 index a6186892..00000000 --- a/ksadk/tui/widgets/chat_input.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Chat input widget with history support.""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Optional, List - -from textual import events -from textual.binding import Binding -from textual.containers import Horizontal, Vertical -from textual.message import Message -from textual.reactive import reactive -from textual.widgets import Static, TextArea - -from ksadk.tui.widgets.history import HistoryManager - -if TYPE_CHECKING: - from textual.app import ComposeResult - - -class ChatTextArea(TextArea): - """TextArea subclass with custom key handling for chat input.""" - - BINDINGS: ClassVar[List[Binding]] = [ - Binding( - "shift+enter,ctrl+j,alt+enter,ctrl+enter", - "insert_newline", - "New Line", - show=False, - priority=True, - ), - Binding( - "ctrl+a", - "select_all_text", - "Select All", - show=False, - priority=True, - ), - ] - - class Submitted(Message): - """Message sent when text is submitted.""" - - def __init__(self, value: str) -> None: - self.value = value - super().__init__() - - class HistoryPrevious(Message): - """Request previous history entry.""" - - def __init__(self, current_text: str) -> None: - self.current_text = current_text - super().__init__() - - class HistoryNext(Message): - """Request next history entry.""" - - def __init__(self, **kwargs: Any) -> None: - kwargs.pop("placeholder", None) - super().__init__(**kwargs) - self._navigating_history = False - - def action_insert_newline(self) -> None: - """Insert a newline character.""" - self.insert("\n") - - def action_select_all_text(self) -> None: - """Select all text in the text area.""" - if not self.text: - return - lines = self.text.split("\n") - end_row = len(lines) - 1 - end_col = len(lines[end_row]) - self.selection = ((0, 0), (end_row, end_col)) - - async def _on_key(self, event: events.Key) -> None: - """Handle key events.""" - if event.key in ("shift+enter", "ctrl+j", "alt+enter", "ctrl+enter"): - event.prevent_default() - event.stop() - self.insert("\n") - return - - if event.key == "enter": - event.prevent_default() - event.stop() - value = self.text.strip() - if value: - self.post_message(self.Submitted(value)) - return - - if event.key == "up": - row, _ = self.cursor_location - if row == 0: - event.prevent_default() - event.stop() - self._navigating_history = True - self.post_message(self.HistoryPrevious(self.text)) - return - - if event.key == "down": - row, _ = self.cursor_location - total_lines = self.text.count("\n") + 1 - if row == total_lines - 1: - event.prevent_default() - event.stop() - self._navigating_history = True - self.post_message(self.HistoryNext()) - return - - await super()._on_key(event) - - def set_text_from_history(self, text: str) -> None: - """Set text from history navigation.""" - self._navigating_history = True - self.text = text - lines = text.split("\n") - last_row = len(lines) - 1 - last_col = len(lines[last_row]) - self.move_cursor((last_row, last_col)) - self._navigating_history = False - - def clear_text(self) -> None: - """Clear the text area.""" - self.text = "" - self.move_cursor((0, 0)) - - -class ChatInput(Vertical): - """Chat input widget with prompt indicator, multi-line text, and history.""" - - DEFAULT_CSS = """ - ChatInput { - height: auto; - min-height: 3; - max-height: 12; - padding: 0; - background: $surface; - border: solid $primary; - } - - ChatInput .input-row { - height: auto; - width: 100%; - } - - ChatInput .input-prompt { - width: 3; - height: 1; - padding: 0 1; - color: $primary; - text-style: bold; - } - - ChatInput ChatTextArea { - width: 1fr; - height: auto; - min-height: 1; - max-height: 8; - border: none; - background: transparent; - padding: 0; - } - - ChatInput ChatTextArea:focus { - border: none; - } - """ - - class Submitted(Message): - """Message sent when input is submitted.""" - - def __init__(self, value: str, mode: str = "normal") -> None: - super().__init__() - self.value = value - self.mode = mode - - mode: reactive[str] = reactive("normal") - - def __init__( - self, - cwd: Optional[str | Path] = None, - history_file: Optional[Path] = None, - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._cwd = Path(cwd) if cwd else Path.cwd() - self._text_area: Optional[ChatTextArea] = None - - if history_file is None: - history_file = Path.home() / ".ksadk" / "history.jsonl" - self._history = HistoryManager(history_file) - - def compose(self) -> ComposeResult: - """Compose the chat input layout.""" - with Horizontal(classes="input-row"): - yield Static(">", classes="input-prompt", id="prompt") - yield ChatTextArea(id="chat-input") - - def on_mount(self) -> None: - """Initialize components after mount.""" - self._text_area = self.query_one("#chat-input", ChatTextArea) - self._text_area.focus() - - def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None: - """Handle text submission.""" - value = event.value - if value: - self._history.add(value) - self.post_message(self.Submitted(value, self.mode)) - if self._text_area: - self._text_area.clear_text() - self.mode = "normal" - - def on_chat_text_area_history_previous(self, event: ChatTextArea.HistoryPrevious) -> None: - """Handle history previous request.""" - entry = self._history.get_previous(event.current_text) - if entry is not None and self._text_area: - self._text_area.set_text_from_history(entry) - - def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None: - """Handle history next request.""" - entry = self._history.get_next() - if entry is not None and self._text_area: - self._text_area.set_text_from_history(entry) - - def focus_input(self) -> None: - if self._text_area: - self._text_area.focus() - - @property - def value(self) -> str: - if self._text_area: - return self._text_area.text - return "" - - @value.setter - def value(self, val: str) -> None: - if self._text_area: - self._text_area.text = val - - def set_disabled(self, *, disabled: bool) -> None: - if self._text_area: - self._text_area.disabled = disabled - if disabled: - self._text_area.blur() diff --git a/ksadk/tui/widgets/history.py b/ksadk/tui/widgets/history.py deleted file mode 100644 index fe457436..00000000 --- a/ksadk/tui/widgets/history.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Command history manager for input persistence.""" - -from __future__ import annotations - -import json -from pathlib import Path # noqa: TC003 - used at runtime in type hints -from typing import List, Optional - - -class HistoryManager: - """Manages command history with file persistence. - - Uses append-only writes for concurrent safety. Multiple agents can - safely write to the same history file without corruption. - """ - - def __init__(self, history_file: Path, max_entries: int = 100) -> None: - """Initialize the history manager. - - Args: - history_file: Path to the JSON-lines history file - max_entries: Maximum number of entries to keep - """ - self.history_file = history_file - self.max_entries = max_entries - self._entries: List[str] = [] - self._current_index: int = -1 - self._temp_input: str = "" - self._load_history() - - def _load_history(self) -> None: - """Load history from file.""" - if not self.history_file.exists(): - return - - try: - with self.history_file.open("r", encoding="utf-8") as f: - entries = [] - for raw_line in f: - line = raw_line.rstrip("\n\r") - if not line: - continue - try: - entry = json.loads(line) - except json.JSONDecodeError: - entry = line - entries.append(entry if isinstance(entry, str) else str(entry)) - self._entries = entries[-self.max_entries :] - except (OSError, UnicodeDecodeError): - self._entries = [] - - def _append_to_file(self, text: str) -> None: - """Append a single entry to history file (concurrent-safe).""" - try: - self.history_file.parent.mkdir(parents=True, exist_ok=True) - with self.history_file.open("a", encoding="utf-8") as f: - f.write(json.dumps(text) + "\n") - except OSError: - pass - - def _compact_history(self) -> None: - """Rewrite history file to remove old entries. - - Only called when entries exceed 2x max_entries to minimize rewrites. - """ - try: - self.history_file.parent.mkdir(parents=True, exist_ok=True) - with self.history_file.open("w", encoding="utf-8") as f: - for entry in self._entries: - f.write(json.dumps(entry) + "\n") - except OSError: - pass - - def add(self, text: str) -> None: - """Add a command to history. - - Args: - text: The command text to add - """ - text = text.strip() - # Skip empty or slash commands ? No, allow slash commands in history usually. - # DeepAgents code skips empty or slash commands. - # But user might want to recall /clear or /thinking. - # Let's keep slash commands in history for ksadk. - if not text: - return - - # Skip duplicates of the last entry - if self._entries and self._entries[-1] == text: - return - - self._entries.append(text) - - # Append to file (fast, concurrent-safe) - self._append_to_file(text) - - # Compact only when we have 2x max entries (rare operation) - if len(self._entries) > self.max_entries * 2: - self._entries = self._entries[-self.max_entries :] - self._compact_history() - - self.reset_navigation() - - def get_previous(self, current_input: str, prefix: str = "") -> Optional[str]: - """Get the previous history entry. - - Args: - current_input: Current input text (saved on first navigation) - prefix: Optional prefix to filter entries - - Returns: - Previous matching entry or None - """ - if not self._entries: - return None - - # Save current input on first navigation - if self._current_index == -1: - self._temp_input = current_input - self._current_index = len(self._entries) - - # Search backwards for matching entry - for i in range(self._current_index - 1, -1, -1): - if self._entries[i].startswith(prefix): - self._current_index = i - return self._entries[i] - - return None - - def get_next(self, prefix: str = "") -> Optional[str]: - """Get the next history entry. - - Args: - prefix: Optional prefix to filter entries - - Returns: - Next matching entry, original input at end, or None - """ - if self._current_index == -1: - return None - - # Search forwards for matching entry - for i in range(self._current_index + 1, len(self._entries)): - if self._entries[i].startswith(prefix): - self._current_index = i - return self._entries[i] - - # Return to original input at the end - result = self._temp_input - self.reset_navigation() - return result - - def reset_navigation(self) -> None: - """Reset navigation state.""" - self._current_index = -1 - self._temp_input = "" diff --git a/ksadk/tui/widgets/system.py b/ksadk/tui/widgets/system.py deleted file mode 100644 index 3a05258d..00000000 --- a/ksadk/tui/widgets/system.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -SystemMessage - 系统消息组件 - -用于显示系统通知、错误等。 -""" - -from textual.widgets import Static -from typing import Optional - -from .base import MessageWidget - - -class SystemMessage(MessageWidget): - """系统消息组件 - - 用于显示系统级通知: - - 信息提示 - - 警告 - - 错误 - """ - - DEFAULT_CSS = """ - SystemMessage { - width: 100%; - padding: 0 1; - margin: 0 0 1 0; - color: #bbbbbb; - background: $surface; - } - - SystemMessage.info { - color: #66ccff; - } - - SystemMessage.info * { - color: #66ccff; - } - - SystemMessage.warning { - color: #ffcc00; - } - - SystemMessage.warning * { - color: #ffcc00; - } - - SystemMessage.error { - color: #ff6666; - } - - SystemMessage.error * { - color: #ff6666; - } - - SystemMessage.success { - color: #66ff66; - } - - SystemMessage.success * { - color: #66ff66; - } - """ - - def __init__( - self, - content: str, - level: str = "info", - **kwargs - ) -> None: - super().__init__(content=content, **kwargs) - self._level = level - self.add_class(level) - - def compose(self): - """构建组件""" - icon = self._get_icon() - yield Static(f"{icon} {self.content}") - - def _get_icon(self) -> str: - """获取图标""" - icons = { - "info": "ℹ️", - "warning": "⚠️", - "error": "❌", - "success": "✅", - } - return icons.get(self._level, "ℹ️") diff --git a/ksadk/tui/widgets/thinking.py b/ksadk/tui/widgets/thinking.py deleted file mode 100644 index de4a9a1c..00000000 --- a/ksadk/tui/widgets/thinking.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -ThinkingMessage - 思考过程组件 - -带 spinner 动画的思考过程展示 -""" - -from textual.widgets import Static -from textual.reactive import reactive -from textual.timer import Timer -from typing import ClassVar, Any -from time import time - -from .base import MessageWidget - - -class ThinkingMessage(MessageWidget): - """思考过程组件 - - 显示 AI 思考过程,支持: - - spinner 动画 - - 实时计时 - - 流式内容追加 - - 可折叠 - """ - - DEFAULT_CSS = """ - ThinkingMessage { - height: auto; - padding: 0 1; - margin: 0 0 1 0; - border-left: wide #7777aa; - color: #ffffff; - background: $surface; - } - - ThinkingMessage .thinking-header { - color: #cccccc; - width: 100%; - } - - ThinkingMessage .thinking-content { - color: #ffffff; - margin-left: 2; - width: 100%; - } - - /* 确保所有子组件都有正确的颜色 */ - ThinkingMessage * { - color: #ffffff; - } - """ - - # Spinner 动画帧 - _SPINNER_FRAMES: ClassVar[tuple[str, ...]] = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") - - is_active: reactive[bool] = reactive(True) - - def __init__(self, content: str = "", **kwargs: Any) -> None: - super().__init__(content=content, **kwargs) - self._thinking_content = content - self._spinner_position = 0 - self._start_time: float = time() - self._animation_timer: Timer | None = None - self._header_widget: Static | None = None - self._content_widget: Static | None = None - - def compose(self): - """构建思考消息布局""" - self._header_widget = Static("🧠 Thinking...", classes="thinking-header") - yield self._header_widget - - self._content_widget = Static(self._thinking_content, classes="thinking-content") - yield self._content_widget - - def on_mount(self) -> None: - """启动动画""" - if self.is_active: - self._animation_timer = self.set_interval(0.1, self._update_animation) - - def _update_animation(self) -> None: - """更新 spinner 动画""" - if not self.is_active or self._header_widget is None: - return - - frame = self._SPINNER_FRAMES[self._spinner_position] - self._spinner_position = (self._spinner_position + 1) % len(self._SPINNER_FRAMES) - - elapsed = int(time() - self._start_time) - self._header_widget.update(f"[bold #ffffff]{frame}[/] [italic #dddddd]Thinking... ({elapsed}s)[/]") - - async def append_content(self, delta: str) -> None: - """追加思考内容""" - self._thinking_content += delta - if self._content_widget: - self._content_widget.update(self._thinking_content) - - def stop(self) -> None: - """停止思考动画""" - self.is_active = False - if self._animation_timer: - self._animation_timer.stop() - self._animation_timer = None - - if self._header_widget: - elapsed = int(time() - self._start_time) - self._header_widget.update(f"[bold #ffffff]💭[/] [italic #aaaaaa]Thought for {elapsed}s[/]") - - @property - def thinking_content(self) -> str: - return self._thinking_content diff --git a/ksadk/tui/widgets/tool_call.py b/ksadk/tui/widgets/tool_call.py deleted file mode 100644 index 0611cbab..00000000 --- a/ksadk/tui/widgets/tool_call.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -ToolCallMessage - 工具调用消息组件 - -显示 Tool Call 状态和参数。 -""" - -from textual.widgets import Static -from textual.reactive import reactive -from textual.containers import Vertical -from typing import Any, Dict, Optional -import json - -from .base import MessageWidget - - -class ToolStatus: - """Tool Call 状态""" - PENDING = "pending" - RUNNING = "running" - SUCCESS = "success" - ERROR = "error" - REJECTED = "rejected" - - -class ToolCallMessage(MessageWidget): - """工具调用消息组件 - - 用于显示 Tool Call 的状态和参数: - - pending: 等待执行(黄色) - - running: 执行中(蓝色动画) - - success: 成功(绿色) - - error: 失败(红色) - - rejected: 被拒绝(灰色) - """ - - DEFAULT_CSS = """ - ToolCallMessage { - width: 100%; - padding: 1; - margin: 0 0 1 0; - border: round $secondary; - background: $surface; - color: #ffffff; - } - - ToolCallMessage.pending { - border: round $warning; - } - - ToolCallMessage.running { - border: round $primary; - } - - ToolCallMessage.success { - border: round $success; - } - - ToolCallMessage.error { - border: round $error; - } - - ToolCallMessage.rejected { - border: round $surface-darken-2; - opacity: 0.7; - } - - ToolCallMessage .tool-header { - text-style: bold; - color: #ffffff; - } - - ToolCallMessage .tool-args { - color: #aaaaaa; - margin-left: 2; - } - - ToolCallMessage .tool-output { - margin-top: 1; - padding: 1; - background: $surface-darken-1; - color: #ffffff; - } - """ - - status: reactive[str] = reactive(ToolStatus.PENDING) - output: reactive[str] = reactive("") - - def __init__( - self, - tool_name: str, - args: Dict[str, Any], - tool_id: Optional[str] = None, - **kwargs - ) -> None: - super().__init__(**kwargs) - self._tool_name = tool_name - self._args = args - self._tool_id = tool_id - self.add_class("pending") - - def compose(self): - """构建组件""" - status_icon = self._get_status_icon() - yield Static( - f"{status_icon} {self._tool_name}", - classes="tool-header" - ) - - # 显示参数(截断过长的值) - args_display = self._format_args(self._args) - yield Static(args_display, classes="tool-args") - - # 输出区域 - if self.output: - yield Static(self.output, classes="tool-output") - - def _get_status_icon(self) -> str: - """获取状态图标""" - icons = { - ToolStatus.PENDING: "⏳", - ToolStatus.RUNNING: "🔄", - ToolStatus.SUCCESS: "✅", - ToolStatus.ERROR: "❌", - ToolStatus.REJECTED: "🚫", - } - return icons.get(self.status, "⏳") - - def _format_args(self, args: Dict[str, Any], max_len: int = 100) -> str: - """格式化参数显示""" - try: - args_str = json.dumps(args, ensure_ascii=False, indent=2) - if len(args_str) > max_len: - args_str = args_str[:max_len] + "..." - return args_str - except Exception: - return str(args)[:max_len] - - def _update_status(self, new_status: str) -> None: - """更新状态""" - self.remove_class(self.status) - self.status = new_status - self.add_class(new_status) - self.refresh() - - def set_running(self) -> None: - """设置为运行中""" - self._update_status(ToolStatus.RUNNING) - - def set_success(self, output: str = "") -> None: - """设置为成功""" - self.output = output - self._update_status(ToolStatus.SUCCESS) - - def set_error(self, error: str = "") -> None: - """设置为失败""" - self.output = f"Error: {error}" - self._update_status(ToolStatus.ERROR) - - def set_rejected(self) -> None: - """设置为被拒绝""" - self._update_status(ToolStatus.REJECTED) diff --git a/ksadk/tui/widgets/user.py b/ksadk/tui/widgets/user.py deleted file mode 100644 index f6f6d755..00000000 --- a/ksadk/tui/widgets/user.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -UserMessage - 用户消息组件 -""" - -from textual.widgets import Static -from rich.text import Text -from typing import Any - -from .base import MessageWidget - - -class UserMessage(MessageWidget): - """用户消息组件 - - 显示用户输入,左边框绿色 - """ - - DEFAULT_CSS = """ - UserMessage { - height: auto; - padding: 0 1; - margin: 1 0 0 0; - background: transparent; - border-left: wide #10b981; - } - """ - - def __init__(self, content: str, **kwargs: Any) -> None: - super().__init__(content=content, **kwargs) - self._content = content - - def compose(self): - """构建用户消息布局""" - text = Text() - text.append("> ", style="bold #10b981") - text.append(self._content, style="#ffffff") - yield Static(text) diff --git a/ksadk/version.py b/ksadk/version.py index 786800f2..3fc829a8 100644 --- a/ksadk/version.py +++ b/ksadk/version.py @@ -1,4 +1,4 @@ """KsADK 版本信息""" -VERSION = "0.6.9" +VERSION = "0.7.0" __version__ = VERSION diff --git a/pyproject.toml b/pyproject.toml index 3f4cb57f..38e9d9d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ksadk" -version = "0.6.9" +version = "0.7.0" description = "KsADK Agent Runtime Platform - unified runtime, debugging, deployment and observability for AI agents" readme = "README.md" requires-python = ">=3.10" @@ -35,8 +35,9 @@ dependencies = [ "pydantic>=2.0.0,<3.0.0", "jsonschema>=4.0.0,<5.0.0", "questionary>=2.0.0", - # TUI 交互界面 (Textual 包含 Rich,无需单独安装) - "textual>=0.50.0", + # TUI 交互界面(prompt_toolkit 全屏布局 + rich 单次渲染依赖) + "prompt_toolkit>=3.0.0", + "rich>=13.0.0", # API 服务 (run/web 命令) "fastapi>=0.100.0,<1.0.0", "uvicorn>=0.23.0", @@ -81,7 +82,7 @@ dependencies = [ # Google ADK 支持 adk = [ "google-adk>=1.34.0,<2.0.0", - "litellm>=1.0.0", + "litellm>=1.0.0; platform_system != 'Windows' or python_version < '3.13'", "json_repair>=0.25.0", # 用于修复大模型输出的非法 JSON ] # LangChain 支持 diff --git a/tests/test_check_approval_record.py b/tests/test_check_approval_record.py index f711e7fd..553c1589 100644 --- a/tests/test_check_approval_record.py +++ b/tests/test_check_approval_record.py @@ -31,7 +31,7 @@ def _approved_record(python_source: str = "cd5fa22b1e78f03a8a9d025017e97ad414fda | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.6.9 | +| Python package version | 0.7.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/ | @@ -70,7 +70,7 @@ def _template_record() -> str: | License | Apache-2.0 | | Python repository | kingsoftcloud/ksadk-python | | Web UI repository | kingsoftcloud/ksadk-web | -| Python package version | 0.6.9 | +| Python package version | 0.7.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/ | @@ -104,7 +104,7 @@ def test_template_approval_record_fails_until_strategy_and_signoffs_are_filled(t checks = module.validate_approval_record( record, - version="0.6.9", + version="0.7.0", expected_current_commit="current-reviewed-commit", ) @@ -125,7 +125,7 @@ def test_filled_approval_record_passes(tmp_path): record = tmp_path / "approval.md" record.write_text(_approved_record(), encoding="utf-8") - checks = module.validate_approval_record(record, version="0.6.9", expected_current_commit="") + checks = module.validate_approval_record(record, version="0.7.0", expected_current_commit="") assert all(check.ok for check in checks) @@ -137,7 +137,7 @@ def test_filled_record_fails_when_source_references_do_not_match_current_commit( checks = module.validate_approval_record( record, - version="0.6.9", + version="0.7.0", expected_current_commit="new-reviewed-commit", ) @@ -160,7 +160,7 @@ def test_filled_record_passes_when_source_references_include_current_commit(tmp_ checks = module.validate_approval_record( record, - version="0.6.9", + version="0.7.0", expected_current_commit="new-reviewed-commit", ) diff --git a/tests/test_public_release_positioning.py b/tests/test_public_release_positioning.py index c2f3040b..d4ea82d8 100644 --- a/tests/test_public_release_positioning.py +++ b/tests/test_public_release_positioning.py @@ -1,11 +1,11 @@ from __future__ import annotations -from pathlib import Path import re import subprocess -import tomllib +from pathlib import Path from urllib.parse import urlparse +import tomllib ROOT = Path(__file__).resolve().parents[1] DOCS_ROOT_URL = "https://kingsoftcloud.github.io/ksadk-python/" @@ -139,14 +139,24 @@ 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.6.9" - assert 'VERSION = "0.6.9"' in version_text + assert pyproject["project"]["version"] == "0.7.0" + assert 'VERSION = "0.7.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"] assert "Agent Development Kit" not in init_text +def test_adk_extra_avoids_litellm_source_build_on_windows_python_3_13(): + pyproject = tomllib.loads(_read("pyproject.toml")) + adk_requirements = pyproject["project"]["optional-dependencies"]["adk"] + + assert ( + "litellm>=1.0.0; platform_system != 'Windows' or python_version < '3.13'" + in adk_requirements + ) + + def test_changelog_marks_0_6_7_ready_for_authorized_release(): changelog = _read("CHANGELOG.md") @@ -178,7 +188,11 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): 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.18' }}" in workflow - assert "KSADK_APPROVED_SOURCE_COMMIT: ${{ github.event.inputs.approved_source_commit || vars.KSADK_APPROVED_SOURCE_COMMIT }}" in workflow + assert ( + "KSADK_APPROVED_SOURCE_COMMIT: " + "${{ github.event.inputs.approved_source_commit || " + "vars.KSADK_APPROVED_SOURCE_COMMIT }}" in workflow + ) assert "make sync-ksadk-web-static" in workflow assert "make public-preflight" in workflow assert "make public-audit public-test public-build-alias-check" in workflow @@ -194,7 +208,10 @@ def test_pypi_publish_workflow_uses_trusted_publishing_and_bundles_ksadk_web(): assert 'KSADK_WEB_VERSION: "0.2.18"' in ci_workflow assert "PUBLIC_KSADK_WEB_VERSION" not in ci_workflow assert "KSADK_WEB_VERSION ?= latest" in makefile - assert "PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py tests/test_config_env_registry.py" in makefile + assert ( + "PUBLIC_TEST_TARGETS ?= tests/test_public_release_positioning.py " + "tests/test_config_env_registry.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 assert "open-source-audit-dist:" in makefile @@ -204,7 +221,10 @@ 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 "public-preflight: public-version-gate public-audit sync-ksadk-web-static public-test docs-site-build public-build-check" in makefile + assert ( + "public-preflight: public-version-gate public-audit sync-ksadk-web-static " + "public-test docs-site-build public-build-check" in makefile + ) assert "NEXT_PUBLIC_BASE_PATH=/ksadk-python pnpm build:static" in makefile assert "PYPI_API_TOKEN" not in workflow assert "password:" not in workflow @@ -231,8 +251,14 @@ def test_public_ci_runs_gitleaks_and_documents_branch_protection(): def test_public_release_approval_template_tracks_current_version(): approval_record = _read("docs/maintainer-approval-record.md") - assert "| Python package version | 0.6.9 |" in approval_record - assert "make public-publish-check PUBLIC_PUBLISH_PHASE=pre-publish V=0.6.9" in approval_record + 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 + + +def test_public_release_sync_compares_exported_file_contents(): + workflow = _read("docs/public-release-workflow.md") + + assert "rsync -a --checksum --delete --exclude .git" in workflow def test_source_repository_does_not_track_generated_ksadk_web_static(): @@ -247,7 +273,11 @@ def test_source_repository_does_not_track_generated_ksadk_web_static(): stdout=subprocess.PIPE, ).stdout else: - web_ui_files = "\n".join(str(path.relative_to(ROOT)) for path in (ROOT / "ksadk/server/web-ui").glob("**/*") if path.is_file()) + web_ui_files = "\n".join( + str(path.relative_to(ROOT)) + for path in (ROOT / "ksadk/server/web-ui").glob("**/*") + if path.is_file() + ) assert "ksadk/server/static/**" in gitignore assert "ksadk/server/web-ui/" in gitignore diff --git a/tests/test_tracing_setup_otlp.py b/tests/test_tracing_setup_otlp.py index 7b522e3a..81781961 100644 --- a/tests/test_tracing_setup_otlp.py +++ b/tests/test_tracing_setup_otlp.py @@ -260,6 +260,7 @@ 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 def test_generic_otlp_langfuse_endpoint_keeps_existing_authorization(monkeypatch): @@ -588,6 +589,103 @@ def clone_span(span, attributes): assert "gen_ai.usage.input_tokens" not in root.attributes +def test_langfuse_transform_strips_openinference_token_counts_when_ksadk_usage_exists( + monkeypatch, +): + _install_fake_otel(monkeypatch) + setup = _reload_setup(monkeypatch) + + class _SpanContext: + def __init__(self, trace_id, span_id): + self.trace_id = trace_id + self.span_id = span_id + + class _Span: + def __init__(self, name, span_id, *, scope_name, attributes=None): + self.name = name + self.context = _SpanContext("trace-a", span_id) + self.parent = None + self.attributes = attributes or {} + self.instrumentation_scope = types.SimpleNamespace(name=scope_name) + + def clone_span(span, attributes): + return _Span( + span.name, + span.context.span_id, + scope_name=span.instrumentation_scope.name, + attributes=attributes, + ) + + monkeypatch.setattr(setup, "_clone_span_with_attributes", clone_span) + + root = _Span( + "0611agent", + 1, + scope_name="ksadk.conversations", + attributes={ + "gen_ai.usage.input_tokens": 2427, + "gen_ai.usage.output_tokens": 37, + }, + ) + child = _Span( + "ChatOpenAI", + 2, + scope_name="openinference.instrumentation.langchain", + attributes={ + "openinference.span.kind": "LLM", + "llm.token_count.prompt": 7860, + "llm.token_count.completion": 108, + "llm.token_count.total": 7968, + "llm.model_name": "deepseek-v4-pro", + }, + ) + + transformed = setup._prepare_langfuse_spans([root, child]) + + assert transformed[0] is root + assert transformed[1] is not child + assert transformed[1].attributes == { + "openinference.span.kind": "CHAIN", + "llm.model_name": "deepseek-v4-pro", + } + assert child.attributes["llm.token_count.prompt"] == 7860 + + +def test_langfuse_transform_keeps_openinference_token_counts_without_ksadk_usage( + monkeypatch, +): + _install_fake_otel(monkeypatch) + setup = _reload_setup(monkeypatch) + + class _SpanContext: + def __init__(self, trace_id, span_id): + self.trace_id = trace_id + self.span_id = span_id + + class _Span: + def __init__(self, name, span_id, *, scope_name, attributes=None): + self.name = name + self.context = _SpanContext("trace-a", span_id) + self.parent = None + self.attributes = attributes or {} + self.instrumentation_scope = types.SimpleNamespace(name=scope_name) + + child = _Span( + "ChatOpenAI", + 1, + scope_name="openinference.instrumentation.langchain", + attributes={ + "openinference.span.kind": "LLM", + "llm.token_count.prompt": 7860, + }, + ) + + transformed = setup._prepare_langfuse_spans([child]) + + assert transformed[0] is child + assert transformed[0].attributes["llm.token_count.prompt"] == 7860 + + def test_langfuse_callback_only_skips_otlp_direct(monkeypatch): trace_api = _install_fake_otel(monkeypatch) monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") diff --git a/uv.lock b/uv.lock index 8925eb05..53f1ed9d 100644 --- a/uv.lock +++ b/uv.lock @@ -2424,7 +2424,7 @@ wheels = [ [[package]] name = "ksadk" -version = "0.6.9" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -2447,6 +2447,7 @@ dependencies = [ { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-sdk" }, { name = "packaging" }, + { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pypdf" }, { name = "python-dotenv" }, @@ -2458,8 +2459,8 @@ dependencies = [ { name = "rapidocr-onnxruntime" }, { name = "requests" }, { name = "requests-aws4auth" }, + { name = "rich" }, { name = "sse-starlette" }, - { name = "textual" }, { name = "uvicorn" }, { name = "websockets" }, ] @@ -2471,7 +2472,7 @@ a2a = [ adk = [ { name = "google-adk" }, { name = "json-repair" }, - { name = "litellm" }, + { name = "litellm", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] all = [ { name = "a2a-sdk", extra = ["http-server"] }, @@ -2486,7 +2487,7 @@ all = [ { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, - { name = "litellm" }, + { name = "litellm", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, { name = "mypy" }, { name = "openinference-instrumentation-langchain" }, { name = "protobuf" }, @@ -2565,13 +2566,14 @@ requires-dist = [ { 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 = "litellm", marker = "extra == 'adk'", specifier = ">=1.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 = "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 = "packaging", specifier = ">=23.0" }, + { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "protobuf", marker = "extra == 'langgraph'", specifier = ">=6.32.1" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pypdf", specifier = ">=6.0.0" }, @@ -2586,9 +2588,9 @@ requires-dist = [ { name = "rapidocr-onnxruntime", specifier = ">=1.2.0" }, { name = "requests", specifier = ">=2.28.0" }, { name = "requests-aws4auth", specifier = ">=1.2.0" }, + { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, - { name = "textual", specifier = ">=0.50.0" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "uvicorn", specifier = ">=0.23.0" }, { name = "websockets", specifier = ">=12.0,<16.0" }, @@ -2874,18 +2876,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] -[[package]] -name = "linkify-it-py" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, -] - [[package]] name = "litellm" version = "1.82.4" @@ -2996,11 +2986,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -3111,18 +3096,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -5813,23 +5786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] -[[package]] -name = "textual" -version = "8.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" }, -] - [[package]] name = "tiktoken" version = "0.12.0" @@ -6064,15 +6020,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "uncalled-for" version = "0.3.2"