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"