agentengine-sdk-python 0.6.9__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.7.0.dist-info}/METADATA +6 -4
- {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.7.0.dist-info}/RECORD +123 -128
- ksadk/api/client.py +19 -0
- ksadk/cli/cmd_create.py +34 -7
- ksadk/cli/cmd_hermes.py +6 -2
- ksadk/cli/cmd_invoke.py +208 -41
- ksadk/cli/cmd_mcp.py +6 -1
- ksadk/cli/cmd_openclaw.py +6 -2
- ksadk/cli/cmd_run.py +13 -6
- ksadk/cli/network_options.py +5 -3
- ksadk/configs/env_registry.py +1 -0
- ksadk/conversations/message_projection.py +269 -0
- ksadk/conversations/model_context.py +11 -1
- ksadk/conversations/runtime.py +81 -13
- ksadk/deployment/base.py +1 -1
- ksadk/deployment/providers/serverless.py +18 -8
- ksadk/memory/adk/resilient_session_service.py +268 -0
- ksadk/memory/adk/short_term_memory.py +21 -6
- ksadk/runners/adk_runner.py +974 -115
- ksadk/runners/langchain_runner.py +219 -7
- ksadk/runners/langgraph_runner.py +145 -12
- ksadk/runners/remote_runner.py +143 -18
- ksadk/server/app.py +148 -6
- ksadk/server/static/assets/{ArtifactsPanel-C0meuOdi.js → ArtifactsPanel-D-SSn2ee.js} +1 -1
- ksadk/server/static/assets/{CodeBlock-BbI4HPtJ.js → CodeBlock-P16VOLYi.js} +1 -1
- ksadk/server/static/assets/{MathMessageMarkdown-DMLQYoLp.js → MathMessageMarkdown-CdGiQWHR.js} +1 -1
- ksadk/server/static/assets/{MermaidBlock-DCR8WZ17.js → MermaidBlock-IOcal8cm.js} +6 -6
- ksadk/server/static/assets/{NativeTerminalPanel-CKUvWhUz.js → NativeTerminalPanel-DGSDWsei.js} +1 -1
- ksadk/server/static/assets/{arc-bj2rZnP5.js → arc-BA13sHc9.js} +1 -1
- ksadk/server/static/assets/architecture-7EHR7CIX-BvUlsA6l.js +1 -0
- ksadk/server/static/assets/{architectureDiagram-3BPJPVTR-2u1ltDIj.js → architectureDiagram-3BPJPVTR-DzJzgVoL.js} +1 -1
- ksadk/server/static/assets/{blockDiagram-GPEHLZMM-D6fvdK20.js → blockDiagram-GPEHLZMM-UTdVcZhn.js} +1 -1
- ksadk/server/static/assets/{c4Diagram-AAUBKEIU-BK82L5n5.js → c4Diagram-AAUBKEIU-Dix0GD4m.js} +1 -1
- ksadk/server/static/assets/channel-CTuydARX.js +1 -0
- ksadk/server/static/assets/{chunk-2J33WTMH-DLVdkeyQ.js → chunk-2J33WTMH-C2JxuZ42.js} +1 -1
- ksadk/server/static/assets/{chunk-4BX2VUAB-BbwsYcbW.js → chunk-4BX2VUAB-Be57QPgD.js} +1 -1
- ksadk/server/static/assets/{chunk-55IACEB6-DOairxzw.js → chunk-55IACEB6-WjSuy4nv.js} +1 -1
- ksadk/server/static/assets/{chunk-727SXJPM-hCO487gs.js → chunk-727SXJPM-BOovP-b5.js} +1 -1
- ksadk/server/static/assets/{chunk-AQP2D5EJ-XU8vDOuG.js → chunk-AQP2D5EJ-DOmZNmWj.js} +1 -1
- ksadk/server/static/assets/{chunk-FMBD7UC4-PrapyfMu.js → chunk-FMBD7UC4-DzwlodkU.js} +1 -1
- ksadk/server/static/assets/{chunk-ND2GUHAM-CboKhTZ3.js → chunk-ND2GUHAM-DMqAikEU.js} +1 -1
- ksadk/server/static/assets/chunk-QZHKN3VN-BnCydivS.js +1 -0
- ksadk/server/static/assets/classDiagram-4FO5ZUOK-BNHg45Be.js +1 -0
- ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-BNHg45Be.js +1 -0
- ksadk/server/static/assets/{cose-bilkent-S5V4N54A-_N8bJ-EU.js → cose-bilkent-S5V4N54A-Zhy2GBnP.js} +1 -1
- ksadk/server/static/assets/{dagre-BM42HDAG-FoDlo5J2.js → dagre-BM42HDAG-CbKrtIUz.js} +1 -1
- ksadk/server/static/assets/{diagram-2AECGRRQ-BBp6ebBw.js → diagram-2AECGRRQ-BRApgQRK.js} +1 -1
- ksadk/server/static/assets/{diagram-5GNKFQAL-CqOvG1PR.js → diagram-5GNKFQAL-D0xbnDiK.js} +1 -1
- ksadk/server/static/assets/{diagram-KO2AKTUF-O9T3t6ba.js → diagram-KO2AKTUF-CVugVI9L.js} +1 -1
- ksadk/server/static/assets/{diagram-LMA3HP47-CGG8ekBO.js → diagram-LMA3HP47-DozUGgee.js} +1 -1
- ksadk/server/static/assets/{diagram-OG6HWLK6-4BmH1EEg.js → diagram-OG6HWLK6-ZOYsKlru.js} +1 -1
- ksadk/server/static/assets/{dist-BmuTWcm7.js → dist-2hInLS_J.js} +1 -1
- ksadk/server/static/assets/{dist-BMKM66B4.js → dist-8EEyGgDx.js} +1 -1
- ksadk/server/static/assets/{dist-Cp1_jKwf.js → dist-8_SfK1IP.js} +1 -1
- ksadk/server/static/assets/{dist-CZ6C3x8B.js → dist-B0NP_Z0z.js} +1 -1
- ksadk/server/static/assets/{dist-DLeM1OCK.js → dist-BIJVDMuB.js} +1 -1
- ksadk/server/static/assets/{dist-Cwhk4lwI.js → dist-BmlMPsCc.js} +1 -1
- ksadk/server/static/assets/{dist-D99wf6_X.js → dist-BrCcQ47T.js} +1 -1
- ksadk/server/static/assets/{dist-CWiTDN_V.js → dist-BssVmjIo.js} +1 -1
- ksadk/server/static/assets/{dist-BVzcWa6D.js → dist-C3HIIaN5.js} +1 -1
- ksadk/server/static/assets/{dist-DM0rEQPk.js → dist-CPBtqRTW.js} +1 -1
- ksadk/server/static/assets/{dist-BkJjNih3.js → dist-CkEKJFtk.js} +1 -1
- ksadk/server/static/assets/{dist-B67uF2uK.js → dist-D-JMdWPQ.js} +1 -1
- ksadk/server/static/assets/{dist-DI-oVAXw.js → dist-D-xOD048.js} +1 -1
- ksadk/server/static/assets/{dist-6kSZvCZh.js → dist-D57wnGWd.js} +1 -1
- ksadk/server/static/assets/{dist-Bk1l05Ni.js → dist-DL_NKc_z.js} +1 -1
- ksadk/server/static/assets/{dist-DbaH3p5h.js → dist-DMezvBGJ.js} +1 -1
- ksadk/server/static/assets/{dist-CiZHB6ZW.js → dist-DmFaq_Iy.js} +1 -1
- ksadk/server/static/assets/{dist-DLMjL01k.js → dist-Dp0k7bWI.js} +1 -1
- ksadk/server/static/assets/{dist-DCpZrjaK.js → dist-DpKtt4Bo.js} +1 -1
- ksadk/server/static/assets/{dist-D5JGcxBx.js → dist-DyOkZmze.js} +1 -1
- ksadk/server/static/assets/{dist-C2DKRFVK.js → dist-DyWsb78Q.js} +1 -1
- ksadk/server/static/assets/{dist-S4klkK5W.js → dist-gX6GOLKq.js} +1 -1
- ksadk/server/static/assets/{erDiagram-TEJ5UH35-oJhaci7S.js → erDiagram-TEJ5UH35-Bk-9bgE5.js} +1 -1
- ksadk/server/static/assets/eventmodeling-FCH6USID-DsAgWnlf.js +1 -0
- ksadk/server/static/assets/{flowDiagram-I6XJVG4X-DtTmUan-.js → flowDiagram-I6XJVG4X-Dn9kZVuZ.js} +1 -1
- ksadk/server/static/assets/{ganttDiagram-6RSMTGT7-BSsb_eij.js → ganttDiagram-6RSMTGT7-BGxXlJqm.js} +1 -1
- ksadk/server/static/assets/gitGraph-WXDBUCRP-BIao9Yjg.js +1 -0
- ksadk/server/static/assets/{gitGraphDiagram-PVQCEYII-BR5s6Pws.js → gitGraphDiagram-PVQCEYII-C7Q0X-wi.js} +1 -1
- ksadk/server/static/assets/{index-BH7-pC0B.js → index-C00Lwg3L.js} +6 -6
- ksadk/server/static/assets/info-J43DQDTF-CyipqGxU.js +1 -0
- ksadk/server/static/assets/{infoDiagram-5YYISTIA-DYPc5cvh.js → infoDiagram-5YYISTIA-BO_CSqq5.js} +1 -1
- ksadk/server/static/assets/{ishikawaDiagram-YF4QCWOH-MoBdALDg.js → ishikawaDiagram-YF4QCWOH-eYLPLd46.js} +1 -1
- ksadk/server/static/assets/{journeyDiagram-JHISSGLW-DgLiQT8l.js → journeyDiagram-JHISSGLW-De9geSb0.js} +1 -1
- ksadk/server/static/assets/{kanban-definition-UN3LZRKU-CsFx112F.js → kanban-definition-UN3LZRKU-Cs80kQto.js} +1 -1
- ksadk/server/static/assets/{katex-DRsiwpM2.js → katex-DSrCFD8n.js} +1 -1
- ksadk/server/static/assets/{linear-TNRQ5Orv.js → linear-CN9sbKTF.js} +1 -1
- ksadk/server/static/assets/{mermaid-parser.core-D2eMVOgo.js → mermaid-parser.core-DlgSBU8l.js} +2 -2
- ksadk/server/static/assets/{mindmap-definition-RKZ34NQL-BtByYFhL.js → mindmap-definition-RKZ34NQL-9afz7Oug.js} +1 -1
- ksadk/server/static/assets/packet-YPE3B663-CGkyRxlg.js +1 -0
- ksadk/server/static/assets/pie-LRSECV5Y-M0uWAf4n.js +1 -0
- ksadk/server/static/assets/{pieDiagram-4H26LBE5-DPnuCB8c.js → pieDiagram-4H26LBE5-DPv_VstN.js} +1 -1
- ksadk/server/static/assets/{quadrantDiagram-W4KKPZXB-miFpCPv4.js → quadrantDiagram-W4KKPZXB-o77wZ1XK.js} +1 -1
- ksadk/server/static/assets/radar-GUYGQ44K-Bdbm24-x.js +1 -0
- ksadk/server/static/assets/{requirementDiagram-4Y6WPE33-mjB_yYkW.js → requirementDiagram-4Y6WPE33-BYVw81VC.js} +1 -1
- ksadk/server/static/assets/{sankeyDiagram-5OEKKPKP-D7hSlZZZ.js → sankeyDiagram-5OEKKPKP-B7nZFMKT.js} +1 -1
- ksadk/server/static/assets/{sequenceDiagram-3UESZ5HK-D9Cx6hC3.js → sequenceDiagram-3UESZ5HK-BGRRrFw5.js} +1 -1
- ksadk/server/static/assets/{stateDiagram-AJRCARHV-D_zrg47o.js → stateDiagram-AJRCARHV-BUfHCoPs.js} +1 -1
- ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-8xBN_gLM.js +1 -0
- ksadk/server/static/assets/{timeline-definition-PNZ67QCA-CwSec0Wa.js → timeline-definition-PNZ67QCA-FbCTGrf9.js} +1 -1
- ksadk/server/static/assets/treeView-BLDUP644-npMo0ak1.js +1 -0
- ksadk/server/static/assets/treemap-LRROVOQU-CHdzrrdo.js +1 -0
- ksadk/server/static/assets/{vennDiagram-CIIHVFJN-agCVobiF.js → vennDiagram-CIIHVFJN-DWgwtPsJ.js} +1 -1
- ksadk/server/static/assets/wardley-L42UT6IY-BEQz7wyy.js +1 -0
- ksadk/server/static/assets/{wardleyDiagram-YWT4CUSO-TmrlhWNt.js → wardleyDiagram-YWT4CUSO-2x8G-tTM.js} +1 -1
- ksadk/server/static/assets/{xychartDiagram-2RQKCTM6-DV71V-ah.js → xychartDiagram-2RQKCTM6-Drma3AYl.js} +1 -1
- ksadk/server/static/index.html +1 -1
- ksadk/sessions/__init__.py +17 -21
- ksadk/sessions/continuity.py +28 -4
- ksadk/sessions/postgres_service.py +77 -14
- ksadk/sessions/resilience.py +52 -0
- ksadk/sessions/resilient.py +360 -0
- ksadk/toolsets/__init__.py +2 -0
- ksadk/toolsets/workspace.py +49 -10
- ksadk/tracing/setup.py +63 -0
- ksadk/tui/__init__.py +22 -7
- ksadk/tui/loop.py +1877 -0
- ksadk/tui/stream_render.py +68 -0
- ksadk/version.py +1 -1
- ksadk/server/static/assets/architecture-7EHR7CIX-Cr6Xz1_c.js +0 -1
- ksadk/server/static/assets/channel-BSSOwFIb.js +0 -1
- ksadk/server/static/assets/chunk-QZHKN3VN-XXpdiwUL.js +0 -1
- ksadk/server/static/assets/classDiagram-4FO5ZUOK-C5HvGiyK.js +0 -1
- ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-C5HvGiyK.js +0 -1
- ksadk/server/static/assets/eventmodeling-FCH6USID-CIgwHJLL.js +0 -1
- ksadk/server/static/assets/gitGraph-WXDBUCRP-BVAJ7uo0.js +0 -1
- ksadk/server/static/assets/info-J43DQDTF-MT80V4ge.js +0 -1
- ksadk/server/static/assets/packet-YPE3B663-BLKhbXZB.js +0 -1
- ksadk/server/static/assets/pie-LRSECV5Y-DFOikjSY.js +0 -1
- ksadk/server/static/assets/radar-GUYGQ44K-095b1Ikw.js +0 -1
- ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-J9kCb0nC.js +0 -1
- ksadk/server/static/assets/treeView-BLDUP644-yinfJEY6.js +0 -1
- ksadk/server/static/assets/treemap-LRROVOQU-DosUqN1Q.js +0 -1
- ksadk/server/static/assets/wardley-L42UT6IY-Bvp7-5uH.js +0 -1
- ksadk/tui/app.py +0 -400
- ksadk/tui/clipboard.py +0 -100
- ksadk/tui/widgets/__init__.py +0 -22
- ksadk/tui/widgets/assistant.py +0 -89
- ksadk/tui/widgets/base.py +0 -54
- ksadk/tui/widgets/chat_input.py +0 -246
- ksadk/tui/widgets/history.py +0 -156
- ksadk/tui/widgets/system.py +0 -87
- ksadk/tui/widgets/thinking.py +0 -110
- ksadk/tui/widgets/tool_call.py +0 -161
- ksadk/tui/widgets/user.py +0 -37
- {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.7.0.dist-info}/WHEEL +0 -0
- {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.7.0.dist-info}/entry_points.txt +0 -0
- {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.7.0.dist-info}/top_level.txt +0 -0
ksadk/runners/adk_runner.py
CHANGED
|
@@ -5,14 +5,19 @@ ADKRunner - Google ADK 框架运行时
|
|
|
5
5
|
支持通过环境变量配置记忆体 (ShortTermMemory / LongTermMemory)。
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import base64
|
|
9
10
|
import inspect
|
|
10
11
|
import logging
|
|
11
12
|
import os
|
|
12
13
|
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from importlib.metadata import PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as _pkg_version
|
|
13
17
|
from pathlib import Path
|
|
14
18
|
from typing import Any, AsyncIterator, Dict, Mapping, Optional
|
|
15
19
|
|
|
20
|
+
from google.genai import types
|
|
16
21
|
from opentelemetry import trace
|
|
17
22
|
|
|
18
23
|
from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes
|
|
@@ -44,6 +49,16 @@ class ADKRunner(BaseRunner):
|
|
|
44
49
|
self._knowledge_base = None
|
|
45
50
|
# Keep runtime toolsets alive for the lifetime of the runner.
|
|
46
51
|
self._runtime_toolsets: list[Any] = []
|
|
52
|
+
# ADK resumability state
|
|
53
|
+
self._resumable: bool = False
|
|
54
|
+
self._resume_disabled_reason: Optional[str] = None
|
|
55
|
+
# P1.1 sub-issue: guard invocation_map read-modify-write so concurrent
|
|
56
|
+
# invocations on the same session don't lose each other's mappings.
|
|
57
|
+
self._invocation_map_lock = asyncio.Lock()
|
|
58
|
+
self._module = None
|
|
59
|
+
self._adk_resume_min_version = os.environ.get(
|
|
60
|
+
"GOOGLE_ADK_RESUME_MIN_VERSION", "1.16.0"
|
|
61
|
+
).strip()
|
|
47
62
|
|
|
48
63
|
async def close(self) -> None:
|
|
49
64
|
"""Close runtime toolsets owned by this runner."""
|
|
@@ -61,48 +76,155 @@ class ADKRunner(BaseRunner):
|
|
|
61
76
|
logger.warning("Failed to close runtime toolset %r: %s", toolset, exc)
|
|
62
77
|
|
|
63
78
|
def _apply_json_patch(self):
|
|
64
|
-
"""
|
|
79
|
+
"""Patch ADK LiteLlm to handle malformed JSON in tool-call arguments.
|
|
80
|
+
|
|
81
|
+
The previous RobustJson approach replaced the entire json module inside
|
|
82
|
+
lite_llm.py, which broke ADK's streaming args-completeness detection:
|
|
83
|
+
ADK uses ``try: json.loads(args) except json.JSONDecodeError: pass``
|
|
84
|
+
to decide whether accumulated streaming args form a complete JSON
|
|
85
|
+
object. When RobustJson.loads swallowed the exception (or even when
|
|
86
|
+
it re-raised after json_repair "fixed" incomplete fragments like
|
|
87
|
+
``{`` into ``{}``), fallback_index incremented on every args fragment,
|
|
88
|
+
causing a single tool call to fragment into multiple entries with
|
|
89
|
+
empty names -- the "Tool '' not found" error.
|
|
90
|
+
|
|
91
|
+
The new approach is surgical: we do NOT replace the json module at
|
|
92
|
+
all. Instead, we patch _message_to_generate_content_response to
|
|
93
|
+
tolerate malformed JSON in the FINAL assembled arguments only.
|
|
94
|
+
The streaming args detection continues using stdlib json.loads and
|
|
95
|
+
gets proper JSONDecodeError for incomplete fragments.
|
|
96
|
+
"""
|
|
65
97
|
try:
|
|
66
|
-
import
|
|
98
|
+
import inspect as _inspect
|
|
99
|
+
import json as _stdlib_json
|
|
67
100
|
|
|
68
101
|
import google.adk.models.lite_llm as adk_lite_llm
|
|
69
102
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
103
|
+
_original_fn = adk_lite_llm._message_to_generate_content_response
|
|
104
|
+
if getattr(_original_fn, "__ksadk_json_patch__", False):
|
|
105
|
+
return
|
|
106
|
+
# Determine which kwargs the original function actually accepts,
|
|
107
|
+
# so the patch is compatible across ADK versions that may have
|
|
108
|
+
# added or removed `model_version` / `thought_parts`.
|
|
109
|
+
_orig_params = set(
|
|
110
|
+
_inspect.signature(_original_fn).parameters.keys()
|
|
111
|
+
)
|
|
74
112
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
def _patched_message_to_generate_content_response(
|
|
114
|
+
message, *, is_partial=False, model_version=None, thought_parts=None
|
|
115
|
+
):
|
|
116
|
+
"""Wrapper that catches JSONDecodeError in final args parsing."""
|
|
117
|
+
# Only forward kwargs that the original function accepts,
|
|
118
|
+
# avoiding TypeError on older ADK versions that lack
|
|
119
|
+
# `model_version` or `thought_parts`.
|
|
120
|
+
forward_kwargs = {"is_partial": is_partial}
|
|
121
|
+
if "model_version" in _orig_params:
|
|
122
|
+
forward_kwargs["model_version"] = model_version
|
|
123
|
+
if "thought_parts" in _orig_params:
|
|
124
|
+
forward_kwargs["thought_parts"] = thought_parts
|
|
125
|
+
try:
|
|
126
|
+
result = _original_fn(message, **forward_kwargs)
|
|
127
|
+
except _stdlib_json.JSONDecodeError:
|
|
128
|
+
logger.warning(
|
|
129
|
+
"ADKRunner: caught JSONDecodeError in args parsing, "
|
|
130
|
+
"returning empty response"
|
|
131
|
+
)
|
|
132
|
+
from google.adk.models import LlmResponse
|
|
133
|
+
from google.genai import types as _genai_types
|
|
134
|
+
|
|
135
|
+
return LlmResponse(
|
|
136
|
+
content=_genai_types.Content(role="model", parts=[]),
|
|
137
|
+
partial=is_partial,
|
|
138
|
+
model_version=model_version,
|
|
139
|
+
)
|
|
140
|
+
# If the response has function_call parts with empty args,
|
|
141
|
+
# fill in {} as fallback (this was what RobustJson used to do,
|
|
142
|
+
# but now only at the final output stage, not during streaming).
|
|
143
|
+
# Also strip phantom function_calls whose name is empty or
|
|
144
|
+
# whitespace-only — these are streaming artifacts produced by
|
|
145
|
+
# the old ADK fallback_index mechanism when parallel tool-call
|
|
146
|
+
# fragments are mis-assembled.
|
|
147
|
+
phantom_indices = set()
|
|
148
|
+
if result.content and result.content.parts:
|
|
149
|
+
for i, part in enumerate(result.content.parts):
|
|
150
|
+
if part.function_call and part.function_call.args is None:
|
|
151
|
+
part.function_call.args = {}
|
|
152
|
+
if (
|
|
153
|
+
part.function_call
|
|
154
|
+
and not (part.function_call.name or "").strip()
|
|
155
|
+
):
|
|
156
|
+
phantom_indices.add(i)
|
|
157
|
+
if phantom_indices:
|
|
158
|
+
result.content.parts = [
|
|
159
|
+
p for i, p in enumerate(result.content.parts)
|
|
160
|
+
if i not in phantom_indices
|
|
161
|
+
]
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
_patched_message_to_generate_content_response.__ksadk_json_patch__ = True
|
|
165
|
+
adk_lite_llm._message_to_generate_content_response = (
|
|
166
|
+
_patched_message_to_generate_content_response
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Don't patch _model_response_to_generate_content_response since
|
|
170
|
+
# it calls _message_to_generate_content_response internally, and
|
|
171
|
+
# that's already patched. Double-patching would cause recursion.
|
|
172
|
+
|
|
173
|
+
logger.info(
|
|
174
|
+
"ADKRunner: Applied surgical args-parsing patch "
|
|
175
|
+
"(stdlib json preserved, streaming detection intact)"
|
|
176
|
+
)
|
|
100
177
|
|
|
101
178
|
except ImportError:
|
|
102
179
|
pass # ADK not installed
|
|
103
180
|
except Exception:
|
|
104
181
|
pass
|
|
105
182
|
|
|
183
|
+
|
|
184
|
+
def _apply_mcp_result_patch(self):
|
|
185
|
+
"""Patch ADK McpTool to convert CallToolResult to dict.
|
|
186
|
+
|
|
187
|
+
Old ADK (1.14.1) McpTool._run_async_impl returns the raw
|
|
188
|
+
CallToolResult Pydantic object from session.call_tool(), which
|
|
189
|
+
cannot be JSON-serialized when ADK builds the FunctionResponse.
|
|
190
|
+
New ADK (1.34.0) added response.model_dump(exclude_none=True,
|
|
191
|
+
mode="json") before returning. This patch replicates that fix
|
|
192
|
+
for the old version.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
from google.adk.tools.mcp_tool.mcp_tool import McpTool
|
|
196
|
+
|
|
197
|
+
_original_run_async = McpTool._run_async_impl
|
|
198
|
+
if getattr(_original_run_async, "__ksadk_mcp_result_patch__", False):
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
async def _patched_run_async_impl(
|
|
202
|
+
self, *, args, tool_context, credential
|
|
203
|
+
):
|
|
204
|
+
response = await _original_run_async(
|
|
205
|
+
self, args=args, tool_context=tool_context,
|
|
206
|
+
credential=credential,
|
|
207
|
+
)
|
|
208
|
+
# If response is a Pydantic model (e.g. CallToolResult),
|
|
209
|
+
# convert to dict so it can be JSON-serialized downstream.
|
|
210
|
+
if hasattr(response, "model_dump"):
|
|
211
|
+
return response.model_dump(exclude_none=True, mode="json")
|
|
212
|
+
return response
|
|
213
|
+
|
|
214
|
+
_patched_run_async_impl.__ksadk_mcp_result_patch__ = True
|
|
215
|
+
McpTool._run_async_impl = _patched_run_async_impl
|
|
216
|
+
|
|
217
|
+
logger.info(
|
|
218
|
+
"ADKRunner: Applied MCP result serialization patch "
|
|
219
|
+
"(CallToolResult -> dict via model_dump)"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
except ImportError:
|
|
223
|
+
pass # ADK or MCP not installed
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
logger.debug("ADKRunner: MCP result patch failed: %s", exc)
|
|
226
|
+
|
|
227
|
+
|
|
106
228
|
def _init_short_term_memory(self):
|
|
107
229
|
"""从环境变量初始化短期记忆
|
|
108
230
|
|
|
@@ -144,6 +266,34 @@ class ADKRunner(BaseRunner):
|
|
|
144
266
|
return ADKSessionAdapter()
|
|
145
267
|
|
|
146
268
|
def describe_checkpoint_capability(self) -> dict[str, Any]:
|
|
269
|
+
resumable = getattr(self, "_resumable", False)
|
|
270
|
+
stm_backend = (
|
|
271
|
+
getattr(self._short_term_memory, "backend", None)
|
|
272
|
+
if self._short_term_memory else None
|
|
273
|
+
)
|
|
274
|
+
if resumable:
|
|
275
|
+
backend = "adk_invocation"
|
|
276
|
+
if stm_backend == "sqlite":
|
|
277
|
+
backend = "adk_invocation+sqlite"
|
|
278
|
+
elif stm_backend == "database":
|
|
279
|
+
backend = "adk_invocation+postgres"
|
|
280
|
+
shared_across_pods = stm_backend == "database"
|
|
281
|
+
return {
|
|
282
|
+
"Supported": shared_across_pods,
|
|
283
|
+
"Backend": backend,
|
|
284
|
+
"Scope": "invocation",
|
|
285
|
+
"Durable": stm_backend is not None and stm_backend != "local",
|
|
286
|
+
"SharedAcrossPods": shared_across_pods,
|
|
287
|
+
"LocalOnly": not shared_across_pods,
|
|
288
|
+
"ResumeMode": "invocation_id",
|
|
289
|
+
"Reason": (
|
|
290
|
+
"ADK ResumabilityConfig and shared database session backend enabled; "
|
|
291
|
+
"resume via invocation_id"
|
|
292
|
+
if shared_across_pods
|
|
293
|
+
else "ADK ResumabilityConfig enabled, but the session backend is "
|
|
294
|
+
"process-local or SQLite and cannot support cross-pod recovery"
|
|
295
|
+
),
|
|
296
|
+
}
|
|
147
297
|
return {
|
|
148
298
|
"Supported": False,
|
|
149
299
|
"Backend": "none",
|
|
@@ -151,37 +301,204 @@ class ADKRunner(BaseRunner):
|
|
|
151
301
|
"Durable": False,
|
|
152
302
|
"SharedAcrossPods": False,
|
|
153
303
|
"ResumeMode": "forward_only",
|
|
154
|
-
"Reason":
|
|
304
|
+
"Reason": (
|
|
305
|
+
self._resume_disabled_reason
|
|
306
|
+
or "ADK ResumabilityConfig not enabled; set "
|
|
307
|
+
"KSADK_ADK_RESUMABLE=1 or configure App with resumability_config"
|
|
308
|
+
),
|
|
155
309
|
}
|
|
156
310
|
|
|
157
311
|
def get_runtime_capabilities(self) -> dict[str, Any]:
|
|
158
312
|
capabilities = super().get_runtime_capabilities()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"KSADK_ADK_SESSION_PATH",
|
|
164
|
-
"KSADK_ADK_SESSION_URL",
|
|
165
|
-
"KSADK_STM_BACKEND",
|
|
166
|
-
"KSADK_STM_PATH",
|
|
167
|
-
"KSADK_STM_URL",
|
|
168
|
-
"KSADK_STM_DB_PATH",
|
|
169
|
-
"KSADK_STM_DB_URL",
|
|
170
|
-
"KSADK_SESSION_BACKEND",
|
|
171
|
-
"KSADK_SESSION_DSN",
|
|
172
|
-
)
|
|
313
|
+
resumable = getattr(self, "_resumable", False)
|
|
314
|
+
stm_backend = (
|
|
315
|
+
getattr(self._short_term_memory, "backend", None)
|
|
316
|
+
if self._short_term_memory else None
|
|
173
317
|
)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"
|
|
178
|
-
"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
318
|
+
is_durable = stm_backend is not None and stm_backend != "local"
|
|
319
|
+
if resumable:
|
|
320
|
+
# P1.3: Level must degrade with backend — in_memory session state
|
|
321
|
+
# cannot survive pod restarts, so "runtime" is misleading.
|
|
322
|
+
level = "runtime" if is_durable else "semantic"
|
|
323
|
+
capabilities["SessionContinuity"] = {
|
|
324
|
+
"Supported": True,
|
|
325
|
+
"Type": "adk_invocation",
|
|
326
|
+
"Level": level,
|
|
327
|
+
"Reason": (
|
|
328
|
+
"ADK ResumabilityConfig enabled with durable session backend, "
|
|
329
|
+
"invocation_id-based checkpoint resume available"
|
|
330
|
+
if is_durable
|
|
331
|
+
else "ADK ResumabilityConfig enabled but session state is in-memory; "
|
|
332
|
+
"resume only works within the same process lifetime"
|
|
333
|
+
),
|
|
334
|
+
}
|
|
335
|
+
else:
|
|
336
|
+
has_native_session = bool(getattr(self, "_short_term_memory", None)) or any(
|
|
337
|
+
str(os.getenv(name) or "").strip()
|
|
338
|
+
for name in (
|
|
339
|
+
"KSADK_ADK_SESSION_BACKEND",
|
|
340
|
+
"KSADK_ADK_SESSION_PATH",
|
|
341
|
+
"KSADK_ADK_SESSION_URL",
|
|
342
|
+
"KSADK_STM_BACKEND",
|
|
343
|
+
"KSADK_STM_PATH",
|
|
344
|
+
"KSADK_STM_URL",
|
|
345
|
+
"KSADK_STM_DB_PATH",
|
|
346
|
+
"KSADK_STM_DB_URL",
|
|
347
|
+
"KSADK_SESSION_BACKEND",
|
|
348
|
+
"KSADK_SESSION_DSN",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
capabilities["SessionContinuity"] = {
|
|
352
|
+
"Supported": True,
|
|
353
|
+
"Type": "native_session" if has_native_session else "semantic_replay",
|
|
354
|
+
"Level": "semantic",
|
|
355
|
+
"Reason": "ADK native session can continue conversation context"
|
|
356
|
+
if has_native_session
|
|
357
|
+
else "conversation transcript can be replayed",
|
|
358
|
+
}
|
|
182
359
|
capabilities["ResumeRun"]["Reason"] = capabilities["Checkpoint"]["Reason"]
|
|
183
360
|
return capabilities
|
|
184
361
|
|
|
362
|
+
@dataclass
|
|
363
|
+
class _ResolvabilityResult:
|
|
364
|
+
enabled: bool
|
|
365
|
+
source: str # "agent_module" | "env" | "auto_persistent_session" | "default"
|
|
366
|
+
app: Any # User module exported App object (if any)
|
|
367
|
+
|
|
368
|
+
def _resolve_resumability(self) -> "_ResolvabilityResult":
|
|
369
|
+
"""从环境变量或 agent 模块推断是否启用 ADK 可恢复性。"""
|
|
370
|
+
# Priority 1: agent module exports app object with resumability_config
|
|
371
|
+
module = getattr(self, "_module", None)
|
|
372
|
+
if module is not None:
|
|
373
|
+
try:
|
|
374
|
+
from google.adk.apps import App
|
|
375
|
+
for attr_name in ("app", "application"):
|
|
376
|
+
candidate = getattr(module, attr_name, None)
|
|
377
|
+
if isinstance(candidate, App) and candidate.resumability_config:
|
|
378
|
+
if candidate.resumability_config.is_resumable:
|
|
379
|
+
return self._ResolvabilityResult(
|
|
380
|
+
enabled=True, source="agent_module", app=candidate)
|
|
381
|
+
except ImportError:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
# Priority 2: environment variable explicit control
|
|
385
|
+
env_val = os.environ.get("KSADK_ADK_RESUMABLE", "").strip().lower()
|
|
386
|
+
if env_val in ("1", "true", "yes"):
|
|
387
|
+
return self._ResolvabilityResult(enabled=True, source="env", app=None)
|
|
388
|
+
|
|
389
|
+
# Priority 3: persistent session backend auto-enable
|
|
390
|
+
stm_backend = (
|
|
391
|
+
getattr(self._short_term_memory, "backend", None)
|
|
392
|
+
if self._short_term_memory else None
|
|
393
|
+
)
|
|
394
|
+
if stm_backend and stm_backend != "local":
|
|
395
|
+
return self._ResolvabilityResult(
|
|
396
|
+
enabled=True, source="auto_persistent_session", app=None)
|
|
397
|
+
|
|
398
|
+
return self._ResolvabilityResult(enabled=False, source="default", app=None)
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _get_adk_version() -> Optional[str]:
|
|
402
|
+
"""返回当前安装的 google-adk 版本号,无法获取时返回 None。"""
|
|
403
|
+
try:
|
|
404
|
+
return _pkg_version("google-adk")
|
|
405
|
+
except PackageNotFoundError:
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
def _check_adk_resume_compatibility(self) -> tuple[bool, str]:
|
|
409
|
+
"""检查当前 google-adk 版本是否支持恢复 (invocation_id)。
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
(compatible, reason) — compatible=True 表示版本足够;reason 为不兼容时的说明文本。
|
|
413
|
+
"""
|
|
414
|
+
adk_ver = self._get_adk_version()
|
|
415
|
+
if adk_ver is None:
|
|
416
|
+
# 无法获取版本信息,保守地认为不兼容
|
|
417
|
+
return False, "google-adk version unknown, cannot guarantee invocation_id support"
|
|
418
|
+
try:
|
|
419
|
+
from packaging.version import Version
|
|
420
|
+
if Version(adk_ver) < Version(self._adk_resume_min_version):
|
|
421
|
+
return (
|
|
422
|
+
False,
|
|
423
|
+
f"google-adk {adk_ver} < {self._adk_resume_min_version}, "
|
|
424
|
+
f"run_async() does not accept invocation_id",
|
|
425
|
+
)
|
|
426
|
+
except Exception:
|
|
427
|
+
# packaging 不可用时,保守地认为不兼容
|
|
428
|
+
return False, "cannot compare google-adk version, assuming incompatible"
|
|
429
|
+
return True, ""
|
|
430
|
+
|
|
431
|
+
def _build_runner(self) -> None:
|
|
432
|
+
"""构造 ADK Runner,优先使用 App 对象以启用 ResumabilityConfig。"""
|
|
433
|
+
from google.adk.runners import Runner
|
|
434
|
+
|
|
435
|
+
resumable = self._resolve_resumability()
|
|
436
|
+
resumability_enabled = resumable.enabled
|
|
437
|
+
|
|
438
|
+
# 版本兼容性检查:低于最低版本时强制关闭恢复
|
|
439
|
+
resume_compatible, resume_reason = self._check_adk_resume_compatibility()
|
|
440
|
+
if not resume_compatible and resumable.enabled:
|
|
441
|
+
logger.warning(
|
|
442
|
+
"ADKRunner: resumability disabled — %s", resume_reason
|
|
443
|
+
)
|
|
444
|
+
resumable = self._ResolvabilityResult(enabled=False, source="version_check", app=None)
|
|
445
|
+
resumability_enabled = False
|
|
446
|
+
self._resume_disabled_reason = resume_reason
|
|
447
|
+
|
|
448
|
+
if resumable.app is not None:
|
|
449
|
+
runner_kwargs = dict(
|
|
450
|
+
app=resumable.app,
|
|
451
|
+
session_service=self._session_service,
|
|
452
|
+
)
|
|
453
|
+
elif resumable.enabled:
|
|
454
|
+
try:
|
|
455
|
+
from google.adk.apps import App, ResumabilityConfig
|
|
456
|
+
app = App(
|
|
457
|
+
name=self._agent.name,
|
|
458
|
+
root_agent=self._agent,
|
|
459
|
+
resumability_config=ResumabilityConfig(is_resumable=True),
|
|
460
|
+
)
|
|
461
|
+
runner_kwargs = dict(
|
|
462
|
+
app=app,
|
|
463
|
+
session_service=self._session_service,
|
|
464
|
+
)
|
|
465
|
+
except ImportError:
|
|
466
|
+
logger.warning(
|
|
467
|
+
"ADK ResumabilityConfig not available (requires google-adk >= 1.14.0); "
|
|
468
|
+
"falling back to non-resumable Runner"
|
|
469
|
+
)
|
|
470
|
+
runner_kwargs = dict(
|
|
471
|
+
agent=self._agent,
|
|
472
|
+
session_service=self._session_service,
|
|
473
|
+
app_name=self._agent.name,
|
|
474
|
+
)
|
|
475
|
+
resumability_enabled = False
|
|
476
|
+
self._resume_disabled_reason = "ADK ResumabilityConfig is unavailable"
|
|
477
|
+
else:
|
|
478
|
+
runner_kwargs = dict(
|
|
479
|
+
agent=self._agent,
|
|
480
|
+
session_service=self._session_service,
|
|
481
|
+
app_name=self._agent.name,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if self._long_term_memory:
|
|
485
|
+
runner_kwargs["memory_service"] = self._long_term_memory
|
|
486
|
+
logger.info("ADKRunner: LongTermMemory injected as memory_service")
|
|
487
|
+
|
|
488
|
+
self._runner = Runner(**runner_kwargs)
|
|
489
|
+
self._resumable = resumability_enabled
|
|
490
|
+
|
|
491
|
+
if resumability_enabled:
|
|
492
|
+
stm_backend = (
|
|
493
|
+
getattr(self._short_term_memory, "backend", None)
|
|
494
|
+
if self._short_term_memory else None
|
|
495
|
+
)
|
|
496
|
+
logger.info(
|
|
497
|
+
"ADKRunner: resumability enabled (source=%s, backend=%s)",
|
|
498
|
+
resumable.source,
|
|
499
|
+
stm_backend or "in_memory",
|
|
500
|
+
)
|
|
501
|
+
|
|
185
502
|
def _init_long_term_memory(self):
|
|
186
503
|
"""从环境变量初始化长期记忆
|
|
187
504
|
|
|
@@ -391,7 +708,9 @@ class ADKRunner(BaseRunner):
|
|
|
391
708
|
backend = (os.environ.get("KSADK_SANDBOX_BACKEND") or "").strip().lower()
|
|
392
709
|
if backend and backend not in {"disabled", "none", "off"}:
|
|
393
710
|
return "sandbox"
|
|
394
|
-
if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get(
|
|
711
|
+
if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get(
|
|
712
|
+
"KSADK_SKILL_RUNTIME_TEMPLATE_ID"
|
|
713
|
+
):
|
|
395
714
|
return "sandbox"
|
|
396
715
|
skills_dir = Path(
|
|
397
716
|
os.environ.get("KSADK_LOCAL_SKILLS_DIR")
|
|
@@ -458,13 +777,18 @@ class ADKRunner(BaseRunner):
|
|
|
458
777
|
return
|
|
459
778
|
added = self._append_tools_by_name(tools)
|
|
460
779
|
if added:
|
|
461
|
-
logger.info(
|
|
780
|
+
logger.info(
|
|
781
|
+
"Injected ksadk built-in tools into agent (added: %s)",
|
|
782
|
+
", ".join(added),
|
|
783
|
+
)
|
|
462
784
|
else:
|
|
463
785
|
logger.debug("ksadk built-in tools already present")
|
|
464
786
|
except Exception as exc:
|
|
465
787
|
logger.warning("Failed to inject ksadk built-in tools: %s", exc)
|
|
466
788
|
|
|
467
|
-
def inject_deferred_tools_for_request(
|
|
789
|
+
def inject_deferred_tools_for_request(
|
|
790
|
+
self, tool_names: list[str] | tuple[str, ...]
|
|
791
|
+
) -> list[str]:
|
|
468
792
|
"""Append direct built-in tools selected by deferred tool search."""
|
|
469
793
|
names = [str(name or "").strip() for name in tool_names or [] if str(name or "").strip()]
|
|
470
794
|
if not names:
|
|
@@ -475,7 +799,10 @@ class ADKRunner(BaseRunner):
|
|
|
475
799
|
tools = get_agentengine_tools(include=names, profile="coding", mode="direct")
|
|
476
800
|
added = self._append_tools_by_name(tools)
|
|
477
801
|
if added:
|
|
478
|
-
logger.info(
|
|
802
|
+
logger.info(
|
|
803
|
+
"Injected deferred ksadk tools for request (added: %s)",
|
|
804
|
+
", ".join(added),
|
|
805
|
+
)
|
|
479
806
|
return added
|
|
480
807
|
except Exception as exc:
|
|
481
808
|
logger.warning("Failed to inject deferred ksadk tools for request: %s", exc)
|
|
@@ -533,6 +860,42 @@ class ADKRunner(BaseRunner):
|
|
|
533
860
|
added_keys.append(key)
|
|
534
861
|
return added_keys
|
|
535
862
|
|
|
863
|
+
def _invalid_agent_name_load_error(self, exc: Exception) -> ValueError | None:
|
|
864
|
+
"""Convert ADK's import-time agent-name validation into an actionable error."""
|
|
865
|
+
errors = getattr(exc, "errors", None)
|
|
866
|
+
if not callable(errors):
|
|
867
|
+
return None
|
|
868
|
+
try:
|
|
869
|
+
details = errors()
|
|
870
|
+
except Exception:
|
|
871
|
+
return None
|
|
872
|
+
if not isinstance(details, list):
|
|
873
|
+
return None
|
|
874
|
+
for detail in details:
|
|
875
|
+
if not isinstance(detail, Mapping):
|
|
876
|
+
continue
|
|
877
|
+
location = detail.get("loc")
|
|
878
|
+
message = str(detail.get("msg") or "")
|
|
879
|
+
name = detail.get("input")
|
|
880
|
+
if location not in (("name",), ["name"]) or not isinstance(name, str):
|
|
881
|
+
continue
|
|
882
|
+
if "valid identifier" not in message.lower():
|
|
883
|
+
continue
|
|
884
|
+
safe_name = "".join(
|
|
885
|
+
char if char.isascii() and (char.isalnum() or char == "_") else "_"
|
|
886
|
+
for char in name
|
|
887
|
+
)
|
|
888
|
+
if not safe_name or safe_name[0].isdigit():
|
|
889
|
+
safe_name = f"agent_{safe_name}"
|
|
890
|
+
return ValueError(
|
|
891
|
+
"ADK Agent 名称不合法: "
|
|
892
|
+
f"{name!r}。Google ADK 要求 `Agent(name=...)` 以英文字母或下划线开头,"
|
|
893
|
+
"且仅包含英文字母、数字和下划线。"
|
|
894
|
+
f"请在 {self.detection_result.entry_point} 中创建 "
|
|
895
|
+
f"{self.detection_result.agent_variable} 时改为,例如 {safe_name!r}。"
|
|
896
|
+
)
|
|
897
|
+
return None
|
|
898
|
+
|
|
536
899
|
def load_agent(self) -> None:
|
|
537
900
|
"""加载 ADK Agent"""
|
|
538
901
|
import warnings
|
|
@@ -540,6 +903,7 @@ class ADKRunner(BaseRunner):
|
|
|
540
903
|
warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.main")
|
|
541
904
|
|
|
542
905
|
self._apply_json_patch()
|
|
906
|
+
self._apply_mcp_result_patch()
|
|
543
907
|
|
|
544
908
|
# 添加项目目录到 Python 路径
|
|
545
909
|
project_path = Path(self.project_dir).resolve()
|
|
@@ -559,6 +923,7 @@ class ADKRunner(BaseRunner):
|
|
|
559
923
|
|
|
560
924
|
try:
|
|
561
925
|
module = __import__(module_name, fromlist=[self.detection_result.agent_variable])
|
|
926
|
+
self._module = module
|
|
562
927
|
self._agent = getattr(module, self.detection_result.agent_variable)
|
|
563
928
|
|
|
564
929
|
# Inject safety instruction for DeepSeek/LLMs to prevent empty tool names
|
|
@@ -575,6 +940,11 @@ class ADKRunner(BaseRunner):
|
|
|
575
940
|
raise AttributeError(
|
|
576
941
|
f"模块 {module_name} 中未找到 {self.detection_result.agent_variable}"
|
|
577
942
|
)
|
|
943
|
+
except Exception as exc:
|
|
944
|
+
name_error = self._invalid_agent_name_load_error(exc)
|
|
945
|
+
if name_error is not None:
|
|
946
|
+
raise name_error from exc
|
|
947
|
+
raise
|
|
578
948
|
|
|
579
949
|
# 验证是否为 ADK Agent
|
|
580
950
|
if not hasattr(self._agent, "name"):
|
|
@@ -590,7 +960,6 @@ class ADKRunner(BaseRunner):
|
|
|
590
960
|
self._inject_search_knowledge_tool()
|
|
591
961
|
|
|
592
962
|
# 初始化 SessionService
|
|
593
|
-
from google.adk.runners import Runner
|
|
594
963
|
from google.adk.sessions import InMemorySessionService
|
|
595
964
|
|
|
596
965
|
if self._short_term_memory:
|
|
@@ -608,17 +977,8 @@ class ADKRunner(BaseRunner):
|
|
|
608
977
|
self._inject_builtin_tools()
|
|
609
978
|
self._inject_mcp_toolsets()
|
|
610
979
|
|
|
611
|
-
# 初始化 Runner (
|
|
612
|
-
|
|
613
|
-
agent=self._agent,
|
|
614
|
-
session_service=self._session_service,
|
|
615
|
-
app_name=self._agent.name,
|
|
616
|
-
)
|
|
617
|
-
if self._long_term_memory:
|
|
618
|
-
runner_kwargs["memory_service"] = self._long_term_memory
|
|
619
|
-
logger.info("ADKRunner: LongTermMemory injected as memory_service")
|
|
620
|
-
|
|
621
|
-
self._runner = Runner(**runner_kwargs)
|
|
980
|
+
# 初始化 Runner (使用 _build_runner 以支持 ResumabilityConfig)
|
|
981
|
+
self._build_runner()
|
|
622
982
|
self._default_model_name = self.normalize_requested_model(
|
|
623
983
|
os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME")
|
|
624
984
|
)
|
|
@@ -730,7 +1090,8 @@ class ADKRunner(BaseRunner):
|
|
|
730
1090
|
return
|
|
731
1091
|
if self._agent is not None:
|
|
732
1092
|
self._apply_model_to_agent_tree(self._agent, normalized)
|
|
733
|
-
|
|
1093
|
+
discovered = self._discover_model_reference(self._agent)
|
|
1094
|
+
self._active_model_name = discovered or target_reference
|
|
734
1095
|
return
|
|
735
1096
|
self._active_model_name = target_reference
|
|
736
1097
|
|
|
@@ -819,8 +1180,7 @@ class ADKRunner(BaseRunner):
|
|
|
819
1180
|
attachments: list[Dict[str, Any]],
|
|
820
1181
|
*,
|
|
821
1182
|
model_metadata: Dict[str, Any] | None = None,
|
|
822
|
-
) ->
|
|
823
|
-
from google.genai import types
|
|
1183
|
+
) -> types.Content:
|
|
824
1184
|
parts = []
|
|
825
1185
|
if text:
|
|
826
1186
|
parts.append(types.Part(text=text))
|
|
@@ -829,7 +1189,10 @@ class ADKRunner(BaseRunner):
|
|
|
829
1189
|
for att in attachments:
|
|
830
1190
|
mime_type = att.get("mime_type", "application/octet-stream")
|
|
831
1191
|
display_name = att.get("display_name", "")
|
|
832
|
-
if
|
|
1192
|
+
if (
|
|
1193
|
+
classify_attachment_kind(str(mime_type), str(display_name)) == "image"
|
|
1194
|
+
and not image_input_supported
|
|
1195
|
+
):
|
|
833
1196
|
skipped_images.append(str(display_name or "未命名图片"))
|
|
834
1197
|
continue
|
|
835
1198
|
|
|
@@ -840,7 +1203,8 @@ class ADKRunner(BaseRunner):
|
|
|
840
1203
|
try:
|
|
841
1204
|
data = base64.b64decode(str(inline_data).strip() + "===")
|
|
842
1205
|
except Exception as e:
|
|
843
|
-
|
|
1206
|
+
att_name = att.get("display_name", "uploaded_file")
|
|
1207
|
+
logger.warning(f"Failed to decode inline attachment {att_name}: {e}")
|
|
844
1208
|
|
|
845
1209
|
if data is None:
|
|
846
1210
|
file_uri = att.get("file_uri")
|
|
@@ -853,7 +1217,10 @@ class ADKRunner(BaseRunner):
|
|
|
853
1217
|
file_uri = att.get("file_uri", "")
|
|
854
1218
|
if file_uri.startswith("local:"):
|
|
855
1219
|
logger.warning(
|
|
856
|
-
|
|
1220
|
+
(
|
|
1221
|
+
"Ignoring direct local attachment reference %s; "
|
|
1222
|
+
"only resolved storage paths are allowed."
|
|
1223
|
+
),
|
|
857
1224
|
file_uri,
|
|
858
1225
|
)
|
|
859
1226
|
|
|
@@ -952,8 +1319,13 @@ class ADKRunner(BaseRunner):
|
|
|
952
1319
|
|
|
953
1320
|
input_tokens = int(usage_metadata.get("prompt_token_count") or 0)
|
|
954
1321
|
output_tokens = int(usage_metadata.get("candidates_token_count") or 0)
|
|
955
|
-
total_tokens = int(
|
|
956
|
-
|
|
1322
|
+
total_tokens = int(
|
|
1323
|
+
usage_metadata.get("total_token_count") or (input_tokens + output_tokens)
|
|
1324
|
+
)
|
|
1325
|
+
if not (
|
|
1326
|
+
input_tokens or output_tokens or total_tokens
|
|
1327
|
+
or input_token_details or output_token_details
|
|
1328
|
+
):
|
|
957
1329
|
return {}
|
|
958
1330
|
return {
|
|
959
1331
|
"input_tokens": input_tokens,
|
|
@@ -967,13 +1339,421 @@ class ADKRunner(BaseRunner):
|
|
|
967
1339
|
def _extract_event_usage(cls, event: Any) -> dict[str, Any]:
|
|
968
1340
|
return cls._normalize_usage_metadata(getattr(event, "usage_metadata", None))
|
|
969
1341
|
|
|
1342
|
+
|
|
1343
|
+
# --- ADK invocation_id & checkpoint helpers ---
|
|
1344
|
+
|
|
1345
|
+
async def _get_max_checkpoint_seq_for_run(
|
|
1346
|
+
self,
|
|
1347
|
+
*,
|
|
1348
|
+
session_id: str,
|
|
1349
|
+
run_id: str,
|
|
1350
|
+
) -> int:
|
|
1351
|
+
"""扫描已有 run_checkpoint 事件,返回同一 run_id 下的最大 checkpoint_seq。
|
|
1352
|
+
|
|
1353
|
+
恢复模式下 checkpoint_seq 从已有最大值继续递增,避免从 0 重启导致
|
|
1354
|
+
checkpoint_id 碰撞并被 append_run_checkpoint_event 的去重逻辑静默丢弃。
|
|
1355
|
+
"""
|
|
1356
|
+
if not run_id:
|
|
1357
|
+
return 0
|
|
1358
|
+
try:
|
|
1359
|
+
from ksadk.sessions import resolve_session_service
|
|
1360
|
+
|
|
1361
|
+
service = resolve_session_service()
|
|
1362
|
+
events = await service.get_events(session_id)
|
|
1363
|
+
max_seq = 0
|
|
1364
|
+
for event in reversed(events):
|
|
1365
|
+
if event.event_type != "run_checkpoint":
|
|
1366
|
+
continue
|
|
1367
|
+
metadata = event.metadata or {}
|
|
1368
|
+
if str(metadata.get("run_id") or "") != str(run_id):
|
|
1369
|
+
continue
|
|
1370
|
+
if str(metadata.get("framework") or "") != "adk":
|
|
1371
|
+
continue
|
|
1372
|
+
checkpoint_id = str(metadata.get("checkpoint_id") or "")
|
|
1373
|
+
if checkpoint_id.startswith("adk-ckpt-"):
|
|
1374
|
+
try:
|
|
1375
|
+
seq = int(checkpoint_id.removeprefix("adk-ckpt-"))
|
|
1376
|
+
if seq > max_seq:
|
|
1377
|
+
max_seq = seq
|
|
1378
|
+
except ValueError:
|
|
1379
|
+
pass
|
|
1380
|
+
return max_seq
|
|
1381
|
+
except Exception as exc:
|
|
1382
|
+
logger.warning(
|
|
1383
|
+
"ADKRunner: failed to query max checkpoint_seq "
|
|
1384
|
+
"for run_id=%s: %s",
|
|
1385
|
+
run_id, exc,
|
|
1386
|
+
)
|
|
1387
|
+
return 0
|
|
1388
|
+
|
|
1389
|
+
async def _collect_adk_invocation_id(
|
|
1390
|
+
self,
|
|
1391
|
+
events_async,
|
|
1392
|
+
*,
|
|
1393
|
+
ksadk_invocation_id: str,
|
|
1394
|
+
session_id: str,
|
|
1395
|
+
checkpoint_run_id: str = "",
|
|
1396
|
+
):
|
|
1397
|
+
"""包装 event 迭代器,在首个 event 到达时采集 ADK invocation_id 并存储映射,
|
|
1398
|
+
同时在可恢复边界写入 checkpoint 事件。
|
|
1399
|
+
|
|
1400
|
+
checkpoint_run_id 用于 checkpoint 的 run_id 字段,恢复模式下沿用原始 RunId
|
|
1401
|
+
以保证同一长任务的 checkpoint 时间线连贯;ksadk_invocation_id 用于
|
|
1402
|
+
invocation_id 映射和 event 级 invocation_id 字段。
|
|
1403
|
+
"""
|
|
1404
|
+
effective_run_id = checkpoint_run_id or ksadk_invocation_id
|
|
1405
|
+
first_event_captured = False
|
|
1406
|
+
captured_adk_invocation_id = ""
|
|
1407
|
+
# 从已有 checkpoint 的最大 seq 继续,避免恢复模式下 ID 碰撞被去重丢弃
|
|
1408
|
+
checkpoint_seq = await self._get_max_checkpoint_seq_for_run(
|
|
1409
|
+
session_id=session_id, run_id=effective_run_id,
|
|
1410
|
+
)
|
|
1411
|
+
async for event in events_async:
|
|
1412
|
+
if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id:
|
|
1413
|
+
first_event_captured = True
|
|
1414
|
+
# P1.1: Use local variable instead of self._last_adk_invocation_id
|
|
1415
|
+
# to avoid cross-session corruption under concurrent runner access.
|
|
1416
|
+
captured_adk_invocation_id = event.invocation_id
|
|
1417
|
+
await self._persist_invocation_mapping(
|
|
1418
|
+
session_id=session_id,
|
|
1419
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1420
|
+
adk_invocation_id=captured_adk_invocation_id,
|
|
1421
|
+
)
|
|
1422
|
+
# 每个 resumable boundary 都立即写 checkpoint,用递增 seq 保证
|
|
1423
|
+
# 即使程序崩溃也有最新恢复点。
|
|
1424
|
+
if self._resumable and first_event_captured and self._is_resumable_boundary(event):
|
|
1425
|
+
checkpoint_seq += 1
|
|
1426
|
+
await self._maybe_write_checkpoint(
|
|
1427
|
+
event=event,
|
|
1428
|
+
session_id=session_id,
|
|
1429
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1430
|
+
adk_invocation_id=captured_adk_invocation_id,
|
|
1431
|
+
checkpoint_seq=checkpoint_seq,
|
|
1432
|
+
checkpoint_run_id=effective_run_id,
|
|
1433
|
+
)
|
|
1434
|
+
yield event
|
|
1435
|
+
|
|
1436
|
+
async def _collect_adk_invocation_id_if_present(
|
|
1437
|
+
self,
|
|
1438
|
+
events_async,
|
|
1439
|
+
*,
|
|
1440
|
+
session_id: str,
|
|
1441
|
+
ksadk_invocation_id: str,
|
|
1442
|
+
):
|
|
1443
|
+
"""轻量版 invocation_id 采集:仅捕获 ID 并持久化映射,不写 checkpoint。"""
|
|
1444
|
+
first_event_captured = False
|
|
1445
|
+
async for event in events_async:
|
|
1446
|
+
if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id:
|
|
1447
|
+
first_event_captured = True
|
|
1448
|
+
# P1.1: Local variable — self._last_adk_invocation_id was removed
|
|
1449
|
+
# to prevent cross-session corruption under concurrent runner access.
|
|
1450
|
+
local_adk_invocation_id = event.invocation_id
|
|
1451
|
+
if ksadk_invocation_id:
|
|
1452
|
+
await self._persist_invocation_mapping(
|
|
1453
|
+
session_id=session_id,
|
|
1454
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1455
|
+
adk_invocation_id=local_adk_invocation_id,
|
|
1456
|
+
)
|
|
1457
|
+
yield event
|
|
1458
|
+
|
|
1459
|
+
async def _persist_invocation_mapping(
|
|
1460
|
+
self,
|
|
1461
|
+
*,
|
|
1462
|
+
session_id: str,
|
|
1463
|
+
ksadk_invocation_id: str,
|
|
1464
|
+
adk_invocation_id: str,
|
|
1465
|
+
) -> None:
|
|
1466
|
+
"""将 ksadk invocation_id → ADK invocation_id 映射持久化到 session binding state。
|
|
1467
|
+
|
|
1468
|
+
存储位置:ksadk_states 表,scope = "runner_binding:adk"
|
|
1469
|
+
state_json 内新增字段 invocation_map: { ksadk_inv_id: adk_inv_id, ... }
|
|
1470
|
+
无需新增数据库表或字段,复用现有 state_json 的 JSON 存储。
|
|
1471
|
+
"""
|
|
1472
|
+
try:
|
|
1473
|
+
from ksadk.sessions import resolve_session_service
|
|
1474
|
+
from ksadk.sessions.continuity import ConversationSessionCore
|
|
1475
|
+
|
|
1476
|
+
service = resolve_session_service()
|
|
1477
|
+
core = ConversationSessionCore(service)
|
|
1478
|
+
# P1.1 sub-issue: hold the lock across the entire read-modify-write
|
|
1479
|
+
# so concurrent invocations on the same session can't lose mappings.
|
|
1480
|
+
async with self._invocation_map_lock:
|
|
1481
|
+
binding = await core.get_binding_by_session_id(session_id, "adk")
|
|
1482
|
+
invocation_map = dict(binding.get("invocation_map") or {})
|
|
1483
|
+
invocation_map[ksadk_invocation_id] = adk_invocation_id
|
|
1484
|
+
|
|
1485
|
+
await core.set_binding_by_session_id(
|
|
1486
|
+
session_id,
|
|
1487
|
+
"adk",
|
|
1488
|
+
{
|
|
1489
|
+
"external_session_id": str(session_id),
|
|
1490
|
+
"internal_session_id": str(session_id),
|
|
1491
|
+
"invocation_map": invocation_map,
|
|
1492
|
+
},
|
|
1493
|
+
)
|
|
1494
|
+
except Exception as exc:
|
|
1495
|
+
logger.warning("Failed to persist ADK invocation mapping: %s", exc)
|
|
1496
|
+
|
|
1497
|
+
async def _resolve_adk_invocation_id(
|
|
1498
|
+
self,
|
|
1499
|
+
*,
|
|
1500
|
+
session_id: str,
|
|
1501
|
+
ksadk_invocation_id: str,
|
|
1502
|
+
) -> str | None:
|
|
1503
|
+
"""从 session binding state 读取 ADK invocation_id 映射。"""
|
|
1504
|
+
try:
|
|
1505
|
+
from ksadk.sessions import resolve_session_service
|
|
1506
|
+
from ksadk.sessions.continuity import ConversationSessionCore
|
|
1507
|
+
|
|
1508
|
+
service = resolve_session_service()
|
|
1509
|
+
core = ConversationSessionCore(service)
|
|
1510
|
+
binding = await core.get_binding_by_session_id(session_id, "adk")
|
|
1511
|
+
invocation_map = dict(binding.get("invocation_map") or {})
|
|
1512
|
+
return invocation_map.get(ksadk_invocation_id)
|
|
1513
|
+
except Exception:
|
|
1514
|
+
return None
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _is_resumable_boundary(self, event: Any) -> bool:
|
|
1518
|
+
"""判断 ADK event 是否为可恢复边界。"""
|
|
1519
|
+
# 工具调用请求
|
|
1520
|
+
if hasattr(event, "get_function_calls") and event.get_function_calls():
|
|
1521
|
+
return True
|
|
1522
|
+
# 自定义 Agent 状态保存
|
|
1523
|
+
if hasattr(event, "actions") and event.actions:
|
|
1524
|
+
if getattr(event.actions, "agent_state", None) is not None:
|
|
1525
|
+
return True
|
|
1526
|
+
if getattr(event.actions, "end_of_agent", False):
|
|
1527
|
+
return True
|
|
1528
|
+
return False
|
|
1529
|
+
|
|
1530
|
+
async def _maybe_write_checkpoint(
|
|
1531
|
+
self,
|
|
1532
|
+
*,
|
|
1533
|
+
event: Any,
|
|
1534
|
+
session_id: str,
|
|
1535
|
+
ksadk_invocation_id: str,
|
|
1536
|
+
adk_invocation_id: str,
|
|
1537
|
+
checkpoint_seq: int,
|
|
1538
|
+
checkpoint_run_id: str = "",
|
|
1539
|
+
) -> None:
|
|
1540
|
+
"""Write a run_checkpoint event at a resumable boundary.
|
|
1541
|
+
|
|
1542
|
+
Each boundary gets its own incrementing checkpoint_id so the latest
|
|
1543
|
+
checkpoint always reflects the latest state for crash recovery.
|
|
1544
|
+
"""
|
|
1545
|
+
if not self._is_resumable_boundary(event):
|
|
1546
|
+
return
|
|
1547
|
+
|
|
1548
|
+
from ksadk.conversations.runtime import append_run_checkpoint_event
|
|
1549
|
+
|
|
1550
|
+
metadata = self._extract_checkpoint_metadata(event)
|
|
1551
|
+
|
|
1552
|
+
effective_run_id = checkpoint_run_id or ksadk_invocation_id
|
|
1553
|
+
await append_run_checkpoint_event(
|
|
1554
|
+
session_id=session_id,
|
|
1555
|
+
author=self._agent.name,
|
|
1556
|
+
run_id=effective_run_id,
|
|
1557
|
+
checkpoint_id=f"adk-ckpt-{checkpoint_seq}",
|
|
1558
|
+
framework="adk",
|
|
1559
|
+
framework_ref={
|
|
1560
|
+
"adk": {
|
|
1561
|
+
"invocation_id": adk_invocation_id,
|
|
1562
|
+
"checkpoint_seq": checkpoint_seq,
|
|
1563
|
+
"event_id": getattr(event, "id", ""),
|
|
1564
|
+
"author": getattr(event, "author", ""),
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
phase=(
|
|
1568
|
+
"tool_call"
|
|
1569
|
+
if (hasattr(event, "get_function_calls") and event.get_function_calls())
|
|
1570
|
+
else "agent_state"
|
|
1571
|
+
),
|
|
1572
|
+
invocation_id=ksadk_invocation_id,
|
|
1573
|
+
metadata=metadata,
|
|
1574
|
+
)
|
|
1575
|
+
logger.debug(
|
|
1576
|
+
"ADKRunner: wrote checkpoint adk-ckpt-%d at boundary "
|
|
1577
|
+
"(session=%s, invocation_id=%s)",
|
|
1578
|
+
checkpoint_seq, session_id, adk_invocation_id,
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
def _extract_checkpoint_metadata(self, event: Any) -> dict[str, Any]:
|
|
1582
|
+
"""从 ADK event 中提取 checkpoint 元数据。"""
|
|
1583
|
+
metadata: dict[str, Any] = {}
|
|
1584
|
+
|
|
1585
|
+
# 工具调用信息
|
|
1586
|
+
if hasattr(event, "get_function_calls") and event.get_function_calls():
|
|
1587
|
+
fcs = event.get_function_calls()
|
|
1588
|
+
metadata["tool_names"] = [fc.name for fc in fcs if hasattr(fc, "name")]
|
|
1589
|
+
metadata["tool_call_ids"] = [fc.id for fc in fcs if hasattr(fc, "id")]
|
|
1590
|
+
|
|
1591
|
+
# Agent 状态信息
|
|
1592
|
+
if hasattr(event, "actions") and event.actions:
|
|
1593
|
+
agent_state = getattr(event.actions, "agent_state", None)
|
|
1594
|
+
if agent_state is not None:
|
|
1595
|
+
if isinstance(agent_state, dict):
|
|
1596
|
+
metadata["agent_state_keys"] = list(agent_state.keys())
|
|
1597
|
+
else:
|
|
1598
|
+
metadata["agent_state_keys"] = []
|
|
1599
|
+
if getattr(event.actions, "end_of_agent", False):
|
|
1600
|
+
metadata["is_terminal"] = True
|
|
1601
|
+
metadata["end_of_agent"] = True
|
|
1602
|
+
|
|
1603
|
+
# 是否可恢复
|
|
1604
|
+
stm_backend = (
|
|
1605
|
+
getattr(self._short_term_memory, "backend", None)
|
|
1606
|
+
if self._short_term_memory else None
|
|
1607
|
+
)
|
|
1608
|
+
shared_across_pods = stm_backend == "database"
|
|
1609
|
+
platform_resumable = self._resumable and shared_across_pods
|
|
1610
|
+
metadata["is_resumable"] = platform_resumable
|
|
1611
|
+
metadata["resume_status"] = "resumable" if platform_resumable else "disabled"
|
|
1612
|
+
metadata["backend"] = stm_backend or "in_memory"
|
|
1613
|
+
metadata["scope"] = "invocation"
|
|
1614
|
+
metadata["durable"] = stm_backend is not None and stm_backend != "local"
|
|
1615
|
+
metadata["shared_across_pods"] = shared_across_pods
|
|
1616
|
+
if not platform_resumable:
|
|
1617
|
+
metadata["resume_disabled_reason"] = (
|
|
1618
|
+
self._resume_disabled_reason
|
|
1619
|
+
if not self._resumable and self._resume_disabled_reason
|
|
1620
|
+
else "ADK checkpoint uses an in-memory or local-only session backend; "
|
|
1621
|
+
"cross-pod resume is unavailable"
|
|
1622
|
+
)
|
|
1623
|
+
# P1.4: ADK only supports invocation-level (forward-only) resume, not
|
|
1624
|
+
# arbitrary checkpoint rollback like LangGraph time_travel. Consumers
|
|
1625
|
+
# should treat only the latest checkpoint as independently resumable.
|
|
1626
|
+
metadata["resume_mode"] = "invocation_id"
|
|
1627
|
+
metadata["only_latest_resumable"] = True
|
|
1628
|
+
|
|
1629
|
+
return metadata
|
|
1630
|
+
|
|
1631
|
+
async def _resolve_resume_invocation_id(
|
|
1632
|
+
self,
|
|
1633
|
+
*,
|
|
1634
|
+
input_data: Dict[str, Any],
|
|
1635
|
+
session_id: str,
|
|
1636
|
+
ksadk_invocation_id: str,
|
|
1637
|
+
) -> str:
|
|
1638
|
+
"""Resolve the ADK invocation_id needed for resume.
|
|
1639
|
+
|
|
1640
|
+
Looks up from framework_ref or session binding mapping.
|
|
1641
|
+
Raises ValueError when the resume reference is missing (P1.2:
|
|
1642
|
+
never silently downgrade to a new invocation).
|
|
1643
|
+
"""
|
|
1644
|
+
adk_invocation_id = None
|
|
1645
|
+
framework_ref = input_data.get("framework_ref") or {}
|
|
1646
|
+
if isinstance(framework_ref, dict):
|
|
1647
|
+
adk_ref = framework_ref.get("adk") or {}
|
|
1648
|
+
if isinstance(adk_ref, dict):
|
|
1649
|
+
adk_invocation_id = adk_ref.get("invocation_id")
|
|
1650
|
+
# Fallback: resolve from session binding
|
|
1651
|
+
if not adk_invocation_id and ksadk_invocation_id:
|
|
1652
|
+
adk_invocation_id = await self._resolve_adk_invocation_id(
|
|
1653
|
+
session_id=session_id,
|
|
1654
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1655
|
+
)
|
|
1656
|
+
if not adk_invocation_id:
|
|
1657
|
+
logger.error(
|
|
1658
|
+
"Resume requested but ADK invocation_id not found for "
|
|
1659
|
+
"session=%s ksadk_inv=%s",
|
|
1660
|
+
session_id, ksadk_invocation_id,
|
|
1661
|
+
)
|
|
1662
|
+
raise ValueError(
|
|
1663
|
+
f"checkpoint_not_resumable: ADK invocation_id not found for "
|
|
1664
|
+
f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}. "
|
|
1665
|
+
f"The checkpoint data may have been lost or the invocation was "
|
|
1666
|
+
f"never persisted."
|
|
1667
|
+
)
|
|
1668
|
+
return adk_invocation_id
|
|
1669
|
+
|
|
1670
|
+
async def _prepare_run_events(
|
|
1671
|
+
self,
|
|
1672
|
+
*,
|
|
1673
|
+
input_data: Dict[str, Any],
|
|
1674
|
+
session_id: str,
|
|
1675
|
+
user_input: str,
|
|
1676
|
+
is_resume: bool,
|
|
1677
|
+
run_config: Optional[Any] = None,
|
|
1678
|
+
) -> AsyncIterator[Any]:
|
|
1679
|
+
"""Prepare the wrapped event stream shared by invoke() and stream().
|
|
1680
|
+
|
|
1681
|
+
Handles new_message construction, resume invocation_id resolution,
|
|
1682
|
+
run_async (with optional run_config for streaming), and event wrapping
|
|
1683
|
+
for checkpoint writing. Returns the wrapped async iterator.
|
|
1684
|
+
"""
|
|
1685
|
+
if not is_resume:
|
|
1686
|
+
new_message = self._build_adk_content(
|
|
1687
|
+
user_input,
|
|
1688
|
+
input_data.get("attachments", []),
|
|
1689
|
+
model_metadata=input_data.get("model_metadata"),
|
|
1690
|
+
)
|
|
1691
|
+
else:
|
|
1692
|
+
new_message = None
|
|
1693
|
+
|
|
1694
|
+
ksadk_invocation_id = str(
|
|
1695
|
+
input_data.get("invocation_id") or input_data.get("run_id") or ""
|
|
1696
|
+
)
|
|
1697
|
+
|
|
1698
|
+
checkpoint_run_id = (
|
|
1699
|
+
str(input_data.get("run_id") or "").strip()
|
|
1700
|
+
if is_resume and str(input_data.get("run_id") or "").strip()
|
|
1701
|
+
else ""
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
run_kwargs: Dict[str, Any] = {
|
|
1705
|
+
"session_id": session_id,
|
|
1706
|
+
"user_id": "ksadk_user",
|
|
1707
|
+
}
|
|
1708
|
+
if run_config is not None:
|
|
1709
|
+
run_kwargs["run_config"] = run_config
|
|
1710
|
+
|
|
1711
|
+
if is_resume:
|
|
1712
|
+
if not self._resumable:
|
|
1713
|
+
raise ValueError(
|
|
1714
|
+
"checkpoint_not_resumable: ADK resumability is disabled for "
|
|
1715
|
+
f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}."
|
|
1716
|
+
)
|
|
1717
|
+
adk_invocation_id = await self._resolve_resume_invocation_id(
|
|
1718
|
+
input_data=input_data,
|
|
1719
|
+
session_id=session_id,
|
|
1720
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1721
|
+
)
|
|
1722
|
+
logger.info("Resuming ADK run with adk_invocation_id: %s", adk_invocation_id)
|
|
1723
|
+
run_kwargs["invocation_id"] = adk_invocation_id
|
|
1724
|
+
else:
|
|
1725
|
+
run_kwargs["new_message"] = new_message
|
|
1726
|
+
run_kwargs["state_delta"] = self._build_state_delta(input_data) or None
|
|
1727
|
+
|
|
1728
|
+
events_async = self._runner.run_async(**run_kwargs)
|
|
1729
|
+
|
|
1730
|
+
if ksadk_invocation_id and self._resumable:
|
|
1731
|
+
wrapped_async = self._collect_adk_invocation_id(
|
|
1732
|
+
events_async,
|
|
1733
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1734
|
+
session_id=session_id,
|
|
1735
|
+
checkpoint_run_id=checkpoint_run_id,
|
|
1736
|
+
)
|
|
1737
|
+
else:
|
|
1738
|
+
wrapped_async = self._collect_adk_invocation_id_if_present(
|
|
1739
|
+
events_async,
|
|
1740
|
+
session_id=session_id,
|
|
1741
|
+
ksadk_invocation_id=ksadk_invocation_id,
|
|
1742
|
+
)
|
|
1743
|
+
return wrapped_async
|
|
1744
|
+
|
|
970
1745
|
async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
971
1746
|
"""调用 ADK Agent"""
|
|
972
|
-
from google.genai import types
|
|
973
1747
|
|
|
974
|
-
|
|
1748
|
+
# 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理
|
|
1749
|
+
is_resume = bool(input_data.get("checkpoint_resume"))
|
|
1750
|
+
raw_input = input_data.get("input")
|
|
1751
|
+
if is_resume and isinstance(raw_input, dict):
|
|
1752
|
+
user_input = "[checkpoint resume]"
|
|
1753
|
+
else:
|
|
1754
|
+
user_input = str(raw_input or "")
|
|
975
1755
|
instructions = str(input_data.get("instructions") or "").strip()
|
|
976
|
-
if instructions:
|
|
1756
|
+
if instructions and not is_resume:
|
|
977
1757
|
user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}"
|
|
978
1758
|
|
|
979
1759
|
# 1. 准备 Metadata (提前以此获取 Agent Name)
|
|
@@ -983,7 +1763,12 @@ class ADKRunner(BaseRunner):
|
|
|
983
1763
|
with tracer.start_as_current_span(trace_name) as span:
|
|
984
1764
|
# Set input.value for Langfuse top-level input display
|
|
985
1765
|
span.set_attribute("input.value", user_input)
|
|
986
|
-
|
|
1766
|
+
truncated_input = (
|
|
1767
|
+
user_input[:200]
|
|
1768
|
+
if isinstance(user_input, str)
|
|
1769
|
+
else str(user_input)[:200]
|
|
1770
|
+
)
|
|
1771
|
+
span.set_attribute("user.input", truncated_input)
|
|
987
1772
|
|
|
988
1773
|
# Use external session ID if provided
|
|
989
1774
|
req_session_id = input_data.get("session_id")
|
|
@@ -999,25 +1784,24 @@ class ADKRunner(BaseRunner):
|
|
|
999
1784
|
if tags:
|
|
1000
1785
|
span.set_attribute("langfuse.tags", ",".join(tags))
|
|
1001
1786
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1787
|
+
wrapped_async = await self._prepare_run_events(
|
|
1788
|
+
input_data=input_data,
|
|
1789
|
+
session_id=session_id,
|
|
1790
|
+
user_input=user_input,
|
|
1791
|
+
is_resume=is_resume,
|
|
1006
1792
|
)
|
|
1007
|
-
state_delta = self._build_state_delta(input_data)
|
|
1008
1793
|
|
|
1009
1794
|
final_response = ""
|
|
1010
1795
|
|
|
1011
1796
|
events_list = []
|
|
1797
|
+
# P1.4: Track last_event to avoid UnboundLocalError when ADK returns
|
|
1798
|
+
# zero events (e.g. resuming a completed invocation).
|
|
1799
|
+
last_event = None
|
|
1012
1800
|
usage: dict[str, Any] = {}
|
|
1013
1801
|
last_usage: dict[str, Any] = {}
|
|
1014
|
-
async for event in
|
|
1015
|
-
session_id=session_id,
|
|
1016
|
-
user_id="ksadk_user",
|
|
1017
|
-
new_message=new_message,
|
|
1018
|
-
state_delta=state_delta or None,
|
|
1019
|
-
):
|
|
1802
|
+
async for event in wrapped_async:
|
|
1020
1803
|
events_list.append(event)
|
|
1804
|
+
last_event = event
|
|
1021
1805
|
event_usage = self._extract_event_usage(event)
|
|
1022
1806
|
if event_usage:
|
|
1023
1807
|
usage = accumulate_usage(usage, event_usage)
|
|
@@ -1028,7 +1812,25 @@ class ADKRunner(BaseRunner):
|
|
|
1028
1812
|
# 过滤掉思考内容 (thought=True),只保留最终答案
|
|
1029
1813
|
is_thought = getattr(part, "thought", False)
|
|
1030
1814
|
if hasattr(part, "text") and part.text and not is_thought:
|
|
1031
|
-
final_response
|
|
1815
|
+
final_response += part.text
|
|
1816
|
+
|
|
1817
|
+
# Prefer the last event's text (usually the complete answer).
|
|
1818
|
+
# When the loop aborts early (MCP error, phantom tool-call),
|
|
1819
|
+
# final_response may hold only intermediate fragments.
|
|
1820
|
+
last_event_text = ""
|
|
1821
|
+
if last_event is not None and hasattr(last_event, "content") and last_event.content:
|
|
1822
|
+
for part in (last_event.content.parts or []):
|
|
1823
|
+
is_thought = getattr(part, "thought", False)
|
|
1824
|
+
if hasattr(part, "text") and part.text and not is_thought:
|
|
1825
|
+
last_event_text += part.text
|
|
1826
|
+
if last_event_text:
|
|
1827
|
+
final_response = last_event_text
|
|
1828
|
+
|
|
1829
|
+
if not final_response and events_list:
|
|
1830
|
+
logger.warning(
|
|
1831
|
+
"ADK invoke finished with events but no final text — "
|
|
1832
|
+
"likely mid-loop abort or tool-call error"
|
|
1833
|
+
)
|
|
1032
1834
|
|
|
1033
1835
|
# Set output.value for Langfuse top-level output display
|
|
1034
1836
|
span.set_attribute("output.value", final_response[:5000] if final_response else "")
|
|
@@ -1046,11 +1848,16 @@ class ADKRunner(BaseRunner):
|
|
|
1046
1848
|
使用 StreamingMode.SSE 启用真正的流式 token 输出
|
|
1047
1849
|
"""
|
|
1048
1850
|
from google.adk.agents.run_config import RunConfig, StreamingMode
|
|
1049
|
-
from google.genai import types
|
|
1050
1851
|
|
|
1051
|
-
|
|
1852
|
+
# 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理
|
|
1853
|
+
is_resume = bool(input_data.get("checkpoint_resume"))
|
|
1854
|
+
raw_input = input_data.get("input")
|
|
1855
|
+
if is_resume and isinstance(raw_input, dict):
|
|
1856
|
+
user_input = "[checkpoint resume]"
|
|
1857
|
+
else:
|
|
1858
|
+
user_input = str(raw_input or "")
|
|
1052
1859
|
instructions = str(input_data.get("instructions") or "").strip()
|
|
1053
|
-
if instructions:
|
|
1860
|
+
if instructions and not is_resume:
|
|
1054
1861
|
user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}"
|
|
1055
1862
|
|
|
1056
1863
|
# 1. 准备 Metadata (提前以此获取 Agent Name)
|
|
@@ -1060,7 +1867,12 @@ class ADKRunner(BaseRunner):
|
|
|
1060
1867
|
with tracer.start_as_current_span(trace_name) as span:
|
|
1061
1868
|
# Set input.value for Langfuse top-level input display
|
|
1062
1869
|
span.set_attribute("input.value", user_input)
|
|
1063
|
-
|
|
1870
|
+
truncated_input = (
|
|
1871
|
+
user_input[:200]
|
|
1872
|
+
if isinstance(user_input, str)
|
|
1873
|
+
else str(user_input)[:200]
|
|
1874
|
+
)
|
|
1875
|
+
span.set_attribute("user.input", truncated_input)
|
|
1064
1876
|
|
|
1065
1877
|
# Use external session ID if provided
|
|
1066
1878
|
req_session_id = input_data.get("session_id")
|
|
@@ -1075,27 +1887,20 @@ class ADKRunner(BaseRunner):
|
|
|
1075
1887
|
if tags:
|
|
1076
1888
|
span.set_attribute("langfuse.tags", ",".join(tags))
|
|
1077
1889
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
input_data
|
|
1081
|
-
|
|
1890
|
+
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
|
1891
|
+
wrapped_async = await self._prepare_run_events(
|
|
1892
|
+
input_data=input_data,
|
|
1893
|
+
session_id=session_id,
|
|
1894
|
+
user_input=user_input,
|
|
1895
|
+
is_resume=is_resume,
|
|
1896
|
+
run_config=run_config,
|
|
1082
1897
|
)
|
|
1083
|
-
state_delta = self._build_state_delta(input_data)
|
|
1084
1898
|
|
|
1085
1899
|
accumulated_text = ""
|
|
1086
1900
|
usage: dict[str, Any] = {}
|
|
1087
1901
|
last_usage: dict[str, Any] = {}
|
|
1088
1902
|
|
|
1089
|
-
|
|
1090
|
-
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
|
|
1091
|
-
|
|
1092
|
-
async for event in self._runner.run_async(
|
|
1093
|
-
session_id=session_id,
|
|
1094
|
-
user_id="ksadk_user",
|
|
1095
|
-
new_message=new_message,
|
|
1096
|
-
state_delta=state_delta or None,
|
|
1097
|
-
run_config=run_config,
|
|
1098
|
-
):
|
|
1903
|
+
async for event in wrapped_async:
|
|
1099
1904
|
event_usage = self._extract_event_usage(event)
|
|
1100
1905
|
if event_usage:
|
|
1101
1906
|
usage = accumulate_usage(usage, event_usage)
|
|
@@ -1113,17 +1918,71 @@ class ADKRunner(BaseRunner):
|
|
|
1113
1918
|
"type": "thinking" if is_thought else "text",
|
|
1114
1919
|
}
|
|
1115
1920
|
|
|
1116
|
-
# 处理工具调用事件
|
|
1921
|
+
# 处理工具调用事件 — ADK 通过 event.content.parts[].function_call
|
|
1922
|
+
# 发出工具调用(即 event.get_function_calls()),而非
|
|
1923
|
+
# event.actions.tool_calls。此处需同时检测两种路径:
|
|
1924
|
+
# (a) get_function_calls() — ADK 标准 Event 模型
|
|
1925
|
+
# (b) actions.tool_calls — 某些旧版或自定义 runner 可能使用
|
|
1926
|
+
emitted_tool_call_ids: set[str] = set()
|
|
1927
|
+
if hasattr(event, "get_function_calls") and event.get_function_calls():
|
|
1928
|
+
for fc in event.get_function_calls():
|
|
1929
|
+
fc_id = getattr(fc, "id", "") or getattr(fc, "name", "unknown")
|
|
1930
|
+
if fc_id in emitted_tool_call_ids:
|
|
1931
|
+
continue
|
|
1932
|
+
emitted_tool_call_ids.add(fc_id)
|
|
1933
|
+
# fc.args 可能是 dict 或 None;确保可序列化
|
|
1934
|
+
fc_args = getattr(fc, "args", None) or {}
|
|
1935
|
+
if not isinstance(fc_args, dict):
|
|
1936
|
+
try:
|
|
1937
|
+
import json as _json
|
|
1938
|
+
fc_args = _json.loads(fc_args) if isinstance(fc_args, str) else {}
|
|
1939
|
+
except Exception:
|
|
1940
|
+
fc_args = {}
|
|
1941
|
+
yield {
|
|
1942
|
+
"type": "tool_call",
|
|
1943
|
+
"tool_name": getattr(fc, "name", "unknown"),
|
|
1944
|
+
"tool_args": fc_args,
|
|
1945
|
+
}
|
|
1117
1946
|
if hasattr(event, "actions") and event.actions:
|
|
1118
1947
|
tool_calls = getattr(event.actions, "tool_calls", None)
|
|
1119
1948
|
if tool_calls:
|
|
1120
1949
|
for tool_call in tool_calls:
|
|
1950
|
+
tc_name = getattr(tool_call, "name", "unknown")
|
|
1951
|
+
tc_id = getattr(tool_call, "id", "") or tc_name
|
|
1952
|
+
if tc_id in emitted_tool_call_ids:
|
|
1953
|
+
continue
|
|
1954
|
+
emitted_tool_call_ids.add(tc_id)
|
|
1121
1955
|
yield {
|
|
1122
1956
|
"type": "tool_call",
|
|
1123
|
-
"tool_name":
|
|
1957
|
+
"tool_name": tc_name,
|
|
1124
1958
|
"tool_args": getattr(tool_call, "input", {}),
|
|
1125
1959
|
}
|
|
1126
1960
|
|
|
1961
|
+
# 处理工具返回结果 — ADK 通过 event.content.parts[].function_response
|
|
1962
|
+
# 发出工具执行结果。当工具执行完毕后 ADK 会产生一个包含
|
|
1963
|
+
# function_response 的事件,此处将其转为 tool_result 语义事件,
|
|
1964
|
+
# 让前端可以感知"某个工具已完成执行"并展示结果。
|
|
1965
|
+
if hasattr(event, "content") and event.content and hasattr(event.content, "parts"):
|
|
1966
|
+
for part in event.content.parts:
|
|
1967
|
+
fr = getattr(part, "function_response", None)
|
|
1968
|
+
if fr is not None:
|
|
1969
|
+
fr_name = getattr(fr, "name", "unknown")
|
|
1970
|
+
fr_output = getattr(fr, "response", None) or {}
|
|
1971
|
+
if not isinstance(fr_output, dict):
|
|
1972
|
+
try:
|
|
1973
|
+
import json as _json2
|
|
1974
|
+
if isinstance(fr_output, str):
|
|
1975
|
+
fr_output = _json2.loads(fr_output)
|
|
1976
|
+
else:
|
|
1977
|
+
fr_output = {"raw": str(fr_output)}
|
|
1978
|
+
except Exception:
|
|
1979
|
+
fr_output = {"raw": str(fr_output)}
|
|
1980
|
+
yield {
|
|
1981
|
+
"type": "tool_result",
|
|
1982
|
+
"tool_name": fr_name,
|
|
1983
|
+
"tool_output": fr_output,
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1127
1986
|
# Set output.value for Langfuse top-level output display
|
|
1128
1987
|
span.set_attribute("output.value", accumulated_text[:5000] if accumulated_text else "")
|
|
1129
1988
|
span.set_attribute("agent.output", accumulated_text[:500])
|