From a547d6ba14ee614e3e33212289cdb5f993ef4f37 Mon Sep 17 00:00:00 2001 From: weimch Date: Mon, 6 Jul 2026 12:10:43 +0800 Subject: [PATCH 01/26] =?UTF-8?q?Bugfix:=20=E4=BF=AE=E5=A4=8DGraphAgent?= =?UTF-8?q?=E4=B8=ADAgentNode=E7=9A=84last=5Fresponse=E8=A2=AB=E8=AE=BE?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E6=80=9D=E8=80=83/=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E8=BD=AE=E6=96=87=E6=9C=AC=E3=80=8D=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原因:没有移除思考内容及做中间轮工具调用Event的检测 - 解决方案:使用Event.is_final_response保证是LLM最后一个结果,然后移除最后一个结果的思考内容 --- .../dsl/graph/_node_action/_agent.py | 18 ++++++++++++------ trpc_agent_sdk/dsl/graph/_node_action/_llm.py | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_agent.py b/trpc_agent_sdk/dsl/graph/_node_action/_agent.py index adf1e7a7..85d064dc 100644 --- a/trpc_agent_sdk/dsl/graph/_node_action/_agent.py +++ b/trpc_agent_sdk/dsl/graph/_node_action/_agent.py @@ -181,11 +181,17 @@ async def execute(self, state: State) -> dict[str, Any]: if isinstance(candidate, str) and candidate: last_response = candidate - if (not self._is_graph_event(event)) and ( - not event.partial) and event.content and event.content.parts: - text_parts = [part.text for part in event.content.parts if part.text] - if text_parts: - last_response = text_parts[-1] + if (not self._is_graph_event(event)) and event.is_final_response(): + # Only clean final answers may become last_response. Skip + # thought parts (part.thought) so a thinking model's + # reasoning monologue is never surfaced as the answer. + # is_final_response() already excludes partial, function + # call/response, and code-execution events, so plan and + # transfer_to_agent rounds are naturally ignored. + if event.content and event.content.parts: + visible_text = [part.text for part in event.content.parts if part.text and not part.thought] + if visible_text: + last_response = "".join(visible_text) if not event.visible: if event.actions and event.actions.transfer_to_agent: @@ -246,7 +252,7 @@ async def execute(self, state: State) -> dict[str, Any]: node_response: Any = last_response structured_output: Any = None - if isinstance(self.agent, LlmAgent) and self.agent.output_schema is not None: + if isinstance(self.agent, LlmAgent) and self.agent.output_schema is not None and last_response: node_response = json.loads(last_response) structured_output = node_response diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_llm.py b/trpc_agent_sdk/dsl/graph/_node_action/_llm.py index 631e4d2a..4c0bcb16 100644 --- a/trpc_agent_sdk/dsl/graph/_node_action/_llm.py +++ b/trpc_agent_sdk/dsl/graph/_node_action/_llm.py @@ -251,7 +251,9 @@ async def _run_model_round( continue response_parts = list(llm_response.content.parts) - text_parts = [part.text for part in response_parts if part.text] + # Exclude thought parts so a thinking model's reasoning does not + # leak into response_text / STATE_KEY_LAST_RESPONSE. + text_parts = [part.text for part in response_parts if part.text and not part.thought] response_text = "".join(text_parts) logger.debug(f"[{self.name}] LLM response received ({len(response_text)} chars)") From cbb69799bda728ce8a02d84feaab7ac3b82c6cf2 Mon Sep 17 00:00:00 2001 From: yangenle6-alt <238854206+yangenle6-alt@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:56:33 +0800 Subject: [PATCH 02/26] feat(a2a): support optional app_name for TrpcA2aAgentService Allow the Runner's app identity to differ from the A2A service_name by adding an optional app_name parameter. Falls back to service_name when not provided, preserving existing behavior for all current callers. Co-authored-by: Cursor --- trpc_agent_sdk/server/a2a/_agent_service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/trpc_agent_sdk/server/a2a/_agent_service.py b/trpc_agent_sdk/server/a2a/_agent_service.py index 508efa54..c7e9df54 100644 --- a/trpc_agent_sdk/server/a2a/_agent_service.py +++ b/trpc_agent_sdk/server/a2a/_agent_service.py @@ -68,6 +68,7 @@ def __init__( *, service_name: str, agent: BaseAgent, + app_name: Optional[str] = None, agent_card: Optional[AgentCard] = None, session_service: Optional[BaseSessionService] = None, memory_service: Optional[BaseMemoryService] = None, @@ -77,6 +78,7 @@ def __init__( self._agent = agent self._agent_card = agent_card self._service_name = service_name + self._app_name = app_name self._session_service = session_service self._memory_service = memory_service self._executor_config = executor_config @@ -110,8 +112,12 @@ async def _initialize(self) -> None: logger.info("Initialized A2A Agent Service %s for %s", self._service_name, self._agent.name) def _create_executor(self) -> TrpcA2aAgentExecutor: + app_name = self._app_name or self._service_name runner = Runner( - app_name=self._service_name, + # Keep the historical service_name default for compatibility, while + # allowing callers to pass an explicit app_name when the Runner app + # identity differs from the A2A transport registration name. + app_name=app_name, agent=self._agent, session_service=self._session_service, memory_service=self._memory_service, From 3f0d67ce685ea9c49e3757448bedb43827acc377 Mon Sep 17 00:00:00 2001 From: congkechen Date: Tue, 7 Jul 2026 14:55:16 +0800 Subject: [PATCH 03/26] Add internal pipeline test --- pipeline_test/patch_llm_call.sh | 59 +++++++++++ pipeline_test/requirements-ecosystem.txt | 17 ++++ pipeline_test/requirements.txt | 13 +++ pipeline_test/run_agent_examples.sh | 124 +++++++++++++++++++++++ pipeline_test/run_ecosystem_examples.sh | 40 ++++++++ pipeline_test/run_evaluation_examples.sh | 14 +++ pipeline_test/run_other_examples.sh | 32 ++++++ 7 files changed, 299 insertions(+) create mode 100644 pipeline_test/patch_llm_call.sh create mode 100644 pipeline_test/requirements-ecosystem.txt create mode 100644 pipeline_test/requirements.txt create mode 100644 pipeline_test/run_agent_examples.sh create mode 100644 pipeline_test/run_ecosystem_examples.sh create mode 100644 pipeline_test/run_evaluation_examples.sh create mode 100644 pipeline_test/run_other_examples.sh diff --git a/pipeline_test/patch_llm_call.sh b/pipeline_test/patch_llm_call.sh new file mode 100644 index 00000000..8fc61fdb --- /dev/null +++ b/pipeline_test/patch_llm_call.sh @@ -0,0 +1,59 @@ +_PS_DIR="${TMPDIR:-/tmp}/trpc_agent_patch_llm_call.$$" +mkdir -p "$_PS_DIR" + +cat > "$_PS_DIR/sitecustomize.py" <<'PYEOF' +import sys + +_TAG = "[patch_llm_call]" + + +def _log(msg): + print(f"{_TAG} {msg}", file=sys.stdout, flush=True) + + +try: + from trpc_agent_sdk.models._openai_model import OpenAIModel +except Exception as e: + _log(f"patch SKIPPED: failed to import OpenAIModel ({type(e).__name__}: {e})") +else: + _orig_init = OpenAIModel.__init__ + + def _patched_init(self, *args, **kwargs): + model_name = args[0] if args else kwargs.get("model_name", "") + before = kwargs.get("enable_thinking", "") + kwargs.setdefault("enable_thinking", False) + _orig_init(self, *args, **kwargs) + _log( + f"OpenAIModel(model_name={model_name!r}) init patched " + f"(caller enable_thinking={before!r}; " + f"final kwarg={kwargs.get('enable_thinking')!r}; " + f"self.enable_thinking={getattr(self, 'enable_thinking', '')!r})" + ) + + OpenAIModel.__init__ = _patched_init + + _orig_extract = OpenAIModel._extract_http_options + + def _patched_extract(self, config): + http_opts = _orig_extract(self, config) or {} + extra_headers = http_opts.setdefault("extra_headers", {}) + extra_headers.setdefault("X-SMG-Routing-Key", "minchangwei") + extra_headers.setdefault("X-SMG-Agent-Name", "trpc-python-agent-pipeline") + extra_body = http_opts.setdefault("extra_body", {}) + chat_template_kwargs = extra_body.setdefault("chat_template_kwargs", {}) + chat_template_kwargs.setdefault("enable_thinking", False) + return http_opts + + OpenAIModel._extract_http_options = _patched_extract + + _log( + f"patch INSTALLED on {OpenAIModel.__module__}.{OpenAIModel.__qualname__} " + f"(layers: __init__ + _extract_http_options; " + f"forces extra_body.chat_template_kwargs.enable_thinking=False; " + f"injects X-SMG-Routing-Key and X-SMG-Agent-Name headers)" + ) +PYEOF + +export PYTHONPATH="$_PS_DIR${PYTHONPATH:+:$PYTHONPATH}" +unset _PS_DIR +echo "[patch_llm_call] OpenAIModel thinking mode will be disabled and SMG headers will be injected (PYTHONPATH updated)." \ No newline at end of file diff --git a/pipeline_test/requirements-ecosystem.txt b/pipeline_test/requirements-ecosystem.txt new file mode 100644 index 00000000..b748a2b1 --- /dev/null +++ b/pipeline_test/requirements-ecosystem.txt @@ -0,0 +1,17 @@ +# proxy pypi +--index-url https://mirrors.cloud.tencent.com/pypi/simple/ +# private pypi +--extra-index-url https://mirrors.tencent.com/repository/pypi/tencent_pypi/simple/ + +trpc_redis==0.2.0a0 +trpc_a2a[redis]>=0.2.7 +trpc_mcp==0.2.1a0 + +a2a-sdk<1.0.0,>=0.3.22 +claude-agent-sdk>=0.1.3,<0.1.64 +cloudpickle>=2.0.0 +ag-ui-protocol>=0.1.8 +aiofiles +mem0ai>=1.0.3 +fastapi +mempalace==3.3.4 \ No newline at end of file diff --git a/pipeline_test/requirements.txt b/pipeline_test/requirements.txt new file mode 100644 index 00000000..e1c03a7d --- /dev/null +++ b/pipeline_test/requirements.txt @@ -0,0 +1,13 @@ +# proxy pypi +--index-url https://mirrors.cloud.tencent.com/pypi/simple/ +# private pypi +--extra-index-url https://mirrors.tencent.com/repository/pypi/tencent_pypi/simple/ + +a2a-sdk<1.0.0,>=0.3.22 +claude-agent-sdk>=0.1.3,<0.1.64 +cloudpickle>=2.0.0 +ag-ui-protocol>=0.1.8 +aiofiles +mem0ai>=1.0.3 +fastapi +mempalace==3.3.4 \ No newline at end of file diff --git a/pipeline_test/run_agent_examples.sh b/pipeline_test/run_agent_examples.sh new file mode 100644 index 00000000..b3973767 --- /dev/null +++ b/pipeline_test/run_agent_examples.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +export DISABLE_TRPC_AGENT_REPORT=true + +set -e + +pip3 install -r pipeline_test/requirements.txt + +# Define example categories +LLM_AGENT_EXAMPLES=( + "examples/quickstart/" + "examples/llmagent/" + "examples/llmagent_with_cancel/" + "examples/llmagent_with_model_create_fn/" + "examples/llmagent_with_parallal_tools/" + "examples/llmagent_with_schema/" + "examples/llmagent_with_thinking/" + "examples/llmagent_with_tool_prompt/" + "examples/llmagent_with_streaming_progress_tool/" + # "examples/memory_service_with_mempalace/" + "examples/webfetch_tool/" + "examples/websearch_tool/" + "examples/code_executors/" + "examples/litellm/" + "examples/graph/" + "examples/graph_multi_turns/" + "examples/graph_with_interrupt/" + "examples/agents/human_in_the_loop/llm_agent.py" + "examples/agents/history_control/max_history_messages.py" + "examples/agents/history_control/timeline_filtering.py" + "examples/agents/history_control/branch_filtering.py" +) + +MULTI_AGENT_EXAMPLES=( + "examples/multi_agent_chain/" + "examples/multi_agent_compose/" + "examples/multi_agent_cycle/" + "examples/multi_agent_parallel/" + "examples/multi_agent_start_from_last/" + "examples/multi_agent_subagent/" +) + +TEAM_AGENT_EXAMPLES=( + "examples/team/" + "examples/team_as_sub_agent/" + "examples/team_human_in_the_loop/" + "examples/team_member_agent_langgraph/" + "examples/team_member_agent_team/" + "examples/team_member_message_filter/" + "examples/team_parallel_execution/" + "examples/team_with_cancel/" +) + +run_examples() { + local examples=("$@") + for example in "${examples[@]}"; do + echo "Running $example..." + if [[ "$example" == *.py ]]; then + python3 "$example" + else + cd "$example" + python3 run_agent.py + cd - + fi + done +} + +run_llm_agent() { + echo "=== Running LLM Agent Examples ===" + run_examples "${LLM_AGENT_EXAMPLES[@]}" +} + +run_multi_agent() { + echo "=== Running Multi Agent Examples ===" + run_examples "${MULTI_AGENT_EXAMPLES[@]}" +} + +run_team_agent() { + echo "=== Running Team Agent Examples ===" + run_examples "${TEAM_AGENT_EXAMPLES[@]}" +} + +run_all() { + run_llm_agent + run_multi_agent + run_team_agent +} + +show_usage() { + echo "Usage: $0 [llm_agent|multi_agent|team_agent]" + echo "" + echo "Parameters:" + echo " llm_agent - Run LLM agent examples (quickstart, llmagent_*, history_control, etc.)" + echo " multi_agent - Run multi agent examples (multi_agent_*)" + echo " team_agent - Run team agent examples (team_*)" + echo "" + echo "If no parameter is provided, all examples will be run." +} + +# Main logic +if [ $# -eq 0 ]; then + run_all +else + case "$1" in + llm_agent) + run_llm_agent + ;; + multi_agent) + run_multi_agent + ;; + team_agent) + run_team_agent + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + echo "Error: Unknown parameter '$1'" + show_usage + exit 1 + ;; + esac +fi \ No newline at end of file diff --git a/pipeline_test/run_ecosystem_examples.sh b/pipeline_test/run_ecosystem_examples.sh new file mode 100644 index 00000000..63711b56 --- /dev/null +++ b/pipeline_test/run_ecosystem_examples.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +export DISABLE_TRPC_AGENT_REPORT=true + +set -e + +pip3 install -r pipeline_test/requirements-ecosystem.txt + +# 启动A2A服务端(后台运行) +echo "启动A2A服务端..." +python3 examples/trpc_a2a/trpc_main.py & +SERVER_PID=$! + +# 等待服务端启动 +sleep 5 + +# 运行A2A客户端测试 +echo "运行A2A客户端测试..." +python3 examples/trpc_a2a/raw_client.py +python3 examples/trpc_a2a/client.py + +# TeamAgent with Remote A2A Member +echo "运行 TeamAgent with Remote A2A Member..." +cd examples/team_member_agent_remote_a2a/ +python3 run_agent.py +cd - + +# 停止服务端 +echo "停止A2A服务端..." +kill $SERVER_PID 2>/dev/null || true + +# TeamAgent with Claude Member +echo "运行 TeamAgent with Claude Member..." +cd examples/team_member_agent_claude/ +python3 run_agent.py +cd - + +# python3 examples/ecosystem/langchain_knowledge/custom_document_loader.py +# python3 examples/ecosystem/langchain_knowledge/custom_retriever.py +# python3 examples/ecosystem/langchain_knowledge/custom_text_splitter.py \ No newline at end of file diff --git a/pipeline_test/run_evaluation_examples.sh b/pipeline_test/run_evaluation_examples.sh new file mode 100644 index 00000000..8d32b6ca --- /dev/null +++ b/pipeline_test/run_evaluation_examples.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +export DISABLE_TRPC_AGENT_REPORT=true + +set -e + +# Evaluation +cd examples/evaluation/quickstart && pytest test_quickstart.py -v -s && cd - +cd examples/evaluation/webui && pytest test_book_finder.py -v -s && cd - +cd examples/evaluation/callbacks && pytest test_callbacks.py -v -s && cd - +cd examples/evaluation/custom_runner && pytest test_custom_runner.py -v -s && cd - +cd examples/evaluation/context_messages && pytest test_context_messages.py -v -s && cd - +cd examples/evaluation/trace_mode && pytest test_trace_mode.py -v -s && cd - +cd examples/evaluation/pass_at_k && pytest test_pass_at_k.py -v -s && cd - \ No newline at end of file diff --git a/pipeline_test/run_other_examples.sh b/pipeline_test/run_other_examples.sh new file mode 100644 index 00000000..c40211fe --- /dev/null +++ b/pipeline_test/run_other_examples.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +export DISABLE_TRPC_AGENT_REPORT=true + +set -e + +# File Tools +cd examples/file_tools/ +python3 run_agent.py +cd - + +# Filter with Agent +cd examples/filter_with_agent/ +python3 run_agent.py +cd - + +# Filter with Model +cd examples/filter_with_model/ +python3 run_agent.py +cd - + +# Filter with Tool +cd examples/filter_with_tool/ +python3 run_agent.py +cd - + +# Session&Memory +python3 examples/session_state/run_agent.py +python3 examples/session_summarizer/run_agent.py + +# Tools +python3 examples/tools/mcp_tools/mcp_tools.py \ No newline at end of file From e08db6d68ca4cfd8b262bfd251966f4895c86061 Mon Sep 17 00:00:00 2001 From: congkechen Date: Tue, 7 Jul 2026 16:55:11 +0800 Subject: [PATCH 04/26] fix pipeline example paths --- pipeline_test/run_agent_examples.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pipeline_test/run_agent_examples.sh b/pipeline_test/run_agent_examples.sh index b3973767..cc20c867 100644 --- a/pipeline_test/run_agent_examples.sh +++ b/pipeline_test/run_agent_examples.sh @@ -25,10 +25,14 @@ LLM_AGENT_EXAMPLES=( "examples/graph/" "examples/graph_multi_turns/" "examples/graph_with_interrupt/" - "examples/agents/human_in_the_loop/llm_agent.py" - "examples/agents/history_control/max_history_messages.py" - "examples/agents/history_control/timeline_filtering.py" - "examples/agents/history_control/branch_filtering.py" + # "examples/agents/human_in_the_loop/llm_agent.py" + # "examples/agents/history_control/max_history_messages.py" + # "examples/agents/history_control/timeline_filtering.py" + # "examples/agents/history_control/branch_filtering.py" + "examples/llmagent_with_human_in_the_loop/" + "examples/llmagent_with_max_history_messages/" + "examples/llmagent_with_timeline_filtering/" + "examples/llmagent_with_branch_filtering/" ) MULTI_AGENT_EXAMPLES=( From 96fcee4289d619a467997ecf91902f4f1b53a0cb Mon Sep 17 00:00:00 2001 From: congkechen Date: Tue, 7 Jul 2026 17:02:22 +0800 Subject: [PATCH 05/26] test internal pipeline trigger --- pipeline_test/internal_pipeline_test_trigger.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 pipeline_test/internal_pipeline_test_trigger.txt diff --git a/pipeline_test/internal_pipeline_test_trigger.txt b/pipeline_test/internal_pipeline_test_trigger.txt new file mode 100644 index 00000000..437e5a71 --- /dev/null +++ b/pipeline_test/internal_pipeline_test_trigger.txt @@ -0,0 +1 @@ +trigger internal pipeline test From 9ed2cfb63038f493fd9b8c28fe0a007f90554665 Mon Sep 17 00:00:00 2001 From: CongkeChen Date: Tue, 7 Jul 2026 17:41:12 +0800 Subject: [PATCH 06/26] Update internal_pipeline_test_trigger.txt --- pipeline_test/internal_pipeline_test_trigger.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipeline_test/internal_pipeline_test_trigger.txt b/pipeline_test/internal_pipeline_test_trigger.txt index 437e5a71..8b137891 100644 --- a/pipeline_test/internal_pipeline_test_trigger.txt +++ b/pipeline_test/internal_pipeline_test_trigger.txt @@ -1 +1 @@ -trigger internal pipeline test + From 778ca59e0ea12acb512ae3eb8c0adfb0e99c5f19 Mon Sep 17 00:00:00 2001 From: congkechen Date: Wed, 8 Jul 2026 10:59:22 +0800 Subject: [PATCH 07/26] fix internal pipeline example scripts --- .../internal_pipeline_test_trigger.txt | 1 - pipeline_test/run_ecosystem_examples.sh | 19 ++++++++++--------- pipeline_test/run_other_examples.sh | 3 ++- 3 files changed, 12 insertions(+), 11 deletions(-) delete mode 100644 pipeline_test/internal_pipeline_test_trigger.txt diff --git a/pipeline_test/internal_pipeline_test_trigger.txt b/pipeline_test/internal_pipeline_test_trigger.txt deleted file mode 100644 index 8b137891..00000000 --- a/pipeline_test/internal_pipeline_test_trigger.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/pipeline_test/run_ecosystem_examples.sh b/pipeline_test/run_ecosystem_examples.sh index 63711b56..8636ee3b 100644 --- a/pipeline_test/run_ecosystem_examples.sh +++ b/pipeline_test/run_ecosystem_examples.sh @@ -8,7 +8,7 @@ pip3 install -r pipeline_test/requirements-ecosystem.txt # 启动A2A服务端(后台运行) echo "启动A2A服务端..." -python3 examples/trpc_a2a/trpc_main.py & +python3 examples/a2a/trpc_main.py & SERVER_PID=$! # 等待服务端启动 @@ -16,14 +16,15 @@ sleep 5 # 运行A2A客户端测试 echo "运行A2A客户端测试..." -python3 examples/trpc_a2a/raw_client.py -python3 examples/trpc_a2a/client.py - -# TeamAgent with Remote A2A Member -echo "运行 TeamAgent with Remote A2A Member..." -cd examples/team_member_agent_remote_a2a/ -python3 run_agent.py -cd - +python3 examples/a2a/test_a2a.py +# python3 examples/a2a/raw_client.py +# python3 examples/a2a/client.py + +# # TeamAgent with Remote A2A Member +# echo "运行 TeamAgent with Remote A2A Member..." +# cd examples/team_member_agent_remote_a2a/ +# python3 run_agent.py +# cd - # 停止服务端 echo "停止A2A服务端..." diff --git a/pipeline_test/run_other_examples.sh b/pipeline_test/run_other_examples.sh index c40211fe..90ffab22 100644 --- a/pipeline_test/run_other_examples.sh +++ b/pipeline_test/run_other_examples.sh @@ -29,4 +29,5 @@ python3 examples/session_state/run_agent.py python3 examples/session_summarizer/run_agent.py # Tools -python3 examples/tools/mcp_tools/mcp_tools.py \ No newline at end of file +# python3 examples/tools/mcp_tools/mcp_tools.py +python3 examples/mcp_tools/run_agent.py \ No newline at end of file From f10f08c6ca914311982e121d286de8e4edc8a7fe Mon Sep 17 00:00:00 2001 From: weimch Date: Wed, 8 Jul 2026 17:31:00 +0800 Subject: [PATCH 08/26] =?UTF-8?q?feat(sessions):=20list=5Fsessions=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20user=5Fid=20=E4=B8=BA=20None=20=E6=97=B6?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=20app=5Fname=20=E4=B8=8B=E5=85=A8=E9=83=A8?= =?UTF-8?q?=20session=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_sessions 的 user_id 参数改为可选,传 None 时返回指定 app 下 所有用户的会话列表(不含 events)。InMemory/SQL/Redis/EvalSessionService 实现统一调整,并补充中英文文档用法说明及单元测试。 --- docs/mkdocs/en/session.md | 12 ++- docs/mkdocs/zh/session.md | 12 ++- .../test_in_memory_session_service.py | 73 +++++++++++++++---- tests/sessions/test_redis_session_service.py | 70 +++++++++++------- tests/sessions/test_sql_session_service.py | 65 ++++++++++++----- trpc_agent_sdk/abc/_session_service.py | 15 +++- .../evaluation/_eval_session_service.py | 2 +- .../sessions/_in_memory_session_service.py | 36 +++++---- .../sessions/_redis_session_service.py | 26 ++++++- .../sessions/_sql_session_service.py | 11 ++- 10 files changed, 232 insertions(+), 90 deletions(-) diff --git a/docs/mkdocs/en/session.md b/docs/mkdocs/en/session.md index c75831da..899bf0cc 100644 --- a/docs/mkdocs/en/session.md +++ b/docs/mkdocs/en/session.md @@ -109,11 +109,18 @@ session = await session_service.get_session( **List Sessions**: ```python +# Specify user_id: returns all sessions for that user (without events) session_list = await session_service.list_sessions( app_name="my_app", user_id="user_001" ) -# Returns ListSessionsResponse, containing all sessions for the user (without events) + +# user_id=None: returns sessions across all users for the app +all_session_list = await session_service.list_sessions( + app_name="my_app", + user_id=None +) +# Returns ListSessionsResponse, containing matching sessions (without events) ``` **Delete Session**: @@ -128,7 +135,7 @@ await session_service.delete_session( **Implementation Logic** (`_base_session_service.py`): - `create_session`: Creates a session, separates and stores app/user/session state - `get_session`: Retrieves a session, merges app/user/session state, applies event filtering -- `list_sessions`: Lists sessions (excludes events to reduce data transfer) +- `list_sessions`: Lists sessions (excludes events to reduce data transfer); passing `user_id=None` returns sessions across all users for the app - `delete_session`: Deletes a session and its associated data --- @@ -611,6 +618,7 @@ session = await session_service.get_session( ) # List existing Sessions +# Specify user_id to return that user's sessions; user_id=None returns sessions across all users for the app session_list = await session_service.list_sessions( app_name=app_name, user_id=user_id diff --git a/docs/mkdocs/zh/session.md b/docs/mkdocs/zh/session.md index 3909973c..c13fc09a 100644 --- a/docs/mkdocs/zh/session.md +++ b/docs/mkdocs/zh/session.md @@ -109,11 +109,18 @@ session = await session_service.get_session( **列出会话**: ```python +# 指定 user_id:返回该用户的所有会话(不含 events) session_list = await session_service.list_sessions( app_name="my_app", user_id="user_001" ) -# 返回 ListSessionsResponse,包含该用户的所有会话(不含 events) + +# user_id 为 None:返回该 app 下所有用户的会话 +all_session_list = await session_service.list_sessions( + app_name="my_app", + user_id=None +) +# 返回 ListSessionsResponse,包含符合条件的所有会话(不含 events) ``` **删除会话**: @@ -128,7 +135,7 @@ await session_service.delete_session( **实现逻辑**(`_base_session_service.py`): - `create_session`:创建会话,分离并存储 app/user/session 状态 - `get_session`:获取会话,合并 app/user/session 状态,应用事件过滤 -- `list_sessions`:列出会话列表(不包含 events,减少数据传输) +- `list_sessions`:列出会话列表(不包含 events,减少数据传输);`user_id` 传 `None` 时返回该 app 下所有用户的会话 - `delete_session`:删除会话及其关联数据 --- @@ -611,6 +618,7 @@ session = await session_service.get_session( ) # 列出存在的 Session +# 指定 user_id 返回该用户的会话;user_id=None 返回该 app 下所有用户的会话 session_list = await session_service.list_sessions( app_name=app_name, user_id=user_id diff --git a/tests/sessions/test_in_memory_session_service.py b/tests/sessions/test_in_memory_session_service.py index 6b33bcd2..174daa31 100644 --- a/tests/sessions/test_in_memory_session_service.py +++ b/tests/sessions/test_in_memory_session_service.py @@ -34,8 +34,9 @@ def _make_session_config(ttl_seconds=0, cleanup_interval=0.0, enable_ttl=False, **kwargs): config = SessionServiceConfig(**kwargs) if enable_ttl: - config.ttl = SessionServiceConfig.create_ttl_config( - enable=True, ttl_seconds=ttl_seconds, cleanup_interval_seconds=cleanup_interval) + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, + ttl_seconds=ttl_seconds, + cleanup_interval_seconds=cleanup_interval) else: config.clean_ttl_config() return config @@ -56,7 +57,9 @@ def _make_event(author="agent", text="hello", state_delta=None, partial=False): # SessionWithTTL # --------------------------------------------------------------------------- + class TestSessionWithTTL: + def test_update_and_get(self): session = Session(id="s1", app_name="app", user_id="user", save_key="k") wrapper = SessionWithTTL(session=session) @@ -85,7 +88,9 @@ def test_get_non_expired(self): # StateWithTTL # --------------------------------------------------------------------------- + class TestStateWithTTL: + def test_update(self): wrapper = StateWithTTL() result = wrapper.update({"key": "value"}) @@ -119,7 +124,9 @@ def test_update_expired_resets_then_updates(self): # InMemorySessionService — create_session # --------------------------------------------------------------------------- + class TestInMemoryCreateSession: + async def test_create_basic_session(self): svc = InMemorySessionService(session_config=_make_session_config()) session = await svc.create_session(app_name="app", user_id="user") @@ -143,13 +150,13 @@ async def test_create_with_whitespace_id_generates_uuid(self): async def test_create_with_state(self): svc = InMemorySessionService(session_config=_make_session_config()) - session = await svc.create_session( - app_name="app", user_id="user", - state={ - "session_key": "session_val", - f"{State.APP_PREFIX}app_key": "app_val", - f"{State.USER_PREFIX}user_key": "user_val", - }) + session = await svc.create_session(app_name="app", + user_id="user", + state={ + "session_key": "session_val", + f"{State.APP_PREFIX}app_key": "app_val", + f"{State.USER_PREFIX}user_key": "user_val", + }) assert session.state["session_key"] == "session_val" assert session.state[f"{State.APP_PREFIX}app_key"] == "app_val" assert session.state[f"{State.USER_PREFIX}user_key"] == "user_val" @@ -168,7 +175,9 @@ async def test_create_returns_deep_copy(self): # InMemorySessionService — get_session # --------------------------------------------------------------------------- + class TestInMemoryGetSession: + async def test_get_existing_session(self): svc = InMemorySessionService(session_config=_make_session_config()) created = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -185,13 +194,14 @@ async def test_get_nonexistent_session(self): async def test_get_returns_merged_state(self): svc = InMemorySessionService(session_config=_make_session_config()) - await svc.create_session( - app_name="app", user_id="user", session_id="s1", - state={ - "sk": "sv", - f"{State.APP_PREFIX}ak": "av", - f"{State.USER_PREFIX}uk": "uv", - }) + await svc.create_session(app_name="app", + user_id="user", + session_id="s1", + state={ + "sk": "sv", + f"{State.APP_PREFIX}ak": "av", + f"{State.USER_PREFIX}uk": "uv", + }) result = await svc.get_session(app_name="app", user_id="user", session_id="s1") assert result.state["sk"] == "sv" assert result.state[f"{State.APP_PREFIX}ak"] == "av" @@ -212,7 +222,9 @@ async def test_get_returns_deep_copy(self): # InMemorySessionService — list_sessions # --------------------------------------------------------------------------- + class TestInMemoryListSessions: + async def test_list_empty(self): svc = InMemorySessionService(session_config=_make_session_config()) result = await svc.list_sessions(app_name="app", user_id="user") @@ -251,12 +263,31 @@ async def test_list_nonexistent_user(self): assert result.sessions == [] await svc.close() + async def test_list_all_users_when_user_id_none(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user1", session_id="s1") + await svc.create_session(app_name="app", user_id="user2", session_id="s2") + result = await svc.list_sessions(app_name="app", user_id=None) + ids = sorted(s.id for s in result.sessions) + assert ids == ["s1", "s2"] + await svc.close() + + async def test_list_all_users_filtered_by_app(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app1", user_id="user1", session_id="s1") + await svc.create_session(app_name="app2", user_id="user1", session_id="s2") + result = await svc.list_sessions(app_name="app1", user_id=None) + assert [s.id for s in result.sessions] == ["s1"] + await svc.close() + # --------------------------------------------------------------------------- # InMemorySessionService — delete_session # --------------------------------------------------------------------------- + class TestInMemoryDeleteSession: + async def test_delete_existing(self): svc = InMemorySessionService(session_config=_make_session_config()) await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -275,7 +306,9 @@ async def test_delete_nonexistent(self): # InMemorySessionService — append_event # --------------------------------------------------------------------------- + class TestInMemoryAppendEvent: + async def test_append_basic(self): svc = InMemorySessionService(session_config=_make_session_config()) session = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -351,7 +384,9 @@ async def test_append_updates_conversation_count(self): # InMemorySessionService — update_session # --------------------------------------------------------------------------- + class TestInMemoryUpdateSession: + async def test_update_existing(self): svc = InMemorySessionService(session_config=_make_session_config()) session = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -384,7 +419,9 @@ async def test_update_nonexistent_session(self): # InMemorySessionService — cleanup # --------------------------------------------------------------------------- + class TestInMemoryCleanupExpired: + async def test_cleanup_removes_expired_sessions(self): config = _make_session_config(enable_ttl=True, ttl_seconds=1, cleanup_interval=3600.0) svc = InMemorySessionService(session_config=config) @@ -436,7 +473,9 @@ async def test_cleanup_nothing_expired(self): # InMemorySessionService — cleanup task lifecycle # --------------------------------------------------------------------------- + class TestInMemoryCleanupTask: + def test_no_cleanup_task_when_ttl_disabled(self): svc = InMemorySessionService(session_config=_make_session_config()) assert svc._InMemorySessionService__cleanup_task is None @@ -490,7 +529,9 @@ async def test_cleanup_loop_handles_error(self): # InMemorySessionService — internal helpers # --------------------------------------------------------------------------- + class TestInMemoryInternalHelpers: + async def test_get_app_state_nonexistent(self): svc = InMemorySessionService(session_config=_make_session_config()) assert svc._get_app_state("nonexistent") == {} diff --git a/tests/sessions/test_redis_session_service.py b/tests/sessions/test_redis_session_service.py index 963fe218..8269b186 100644 --- a/tests/sessions/test_redis_session_service.py +++ b/tests/sessions/test_redis_session_service.py @@ -27,15 +27,12 @@ from trpc_agent_sdk.types import Content, EventActions, Part, State -def _make_config(ttl_seconds=0, - cleanup_interval=0.0, - enable_ttl=False, - max_events=0, - store_historical_events=False): +def _make_config(ttl_seconds=0, cleanup_interval=0.0, enable_ttl=False, max_events=0, store_historical_events=False): config = SessionServiceConfig(max_events=max_events, store_historical_events=store_historical_events) if enable_ttl: - config.ttl = SessionServiceConfig.create_ttl_config( - enable=True, ttl_seconds=ttl_seconds, cleanup_interval_seconds=cleanup_interval) + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, + ttl_seconds=ttl_seconds, + cleanup_interval_seconds=cleanup_interval) else: config.clean_ttl_config() return config @@ -117,6 +114,7 @@ def _create_service(config=None): class TestRedisCreateSession: + async def test_create_basic(self): svc = _create_service() session = await svc.create_session(app_name="app", user_id="user") @@ -133,13 +131,13 @@ async def test_create_with_custom_id(self): async def test_create_with_state(self): svc = _create_service() - session = await svc.create_session( - app_name="app", user_id="user", - state={ - "sk": "sv", - f"{State.APP_PREFIX}ak": "av", - f"{State.USER_PREFIX}uk": "uv", - }) + session = await svc.create_session(app_name="app", + user_id="user", + state={ + "sk": "sv", + f"{State.APP_PREFIX}ak": "av", + f"{State.USER_PREFIX}uk": "uv", + }) assert session.state["sk"] == "sv" assert session.state[f"{State.APP_PREFIX}ak"] == "av" assert session.state[f"{State.USER_PREFIX}uk"] == "uv" @@ -154,6 +152,7 @@ async def test_create_with_whitespace_id(self): class TestRedisGetSession: + async def test_get_existing(self): svc = _create_service() await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -170,13 +169,14 @@ async def test_get_nonexistent(self): async def test_get_with_merged_state(self): svc = _create_service() - await svc.create_session( - app_name="app", user_id="user", session_id="s1", - state={ - "sk": "sv", - f"{State.APP_PREFIX}ak": "av", - f"{State.USER_PREFIX}uk": "uv", - }) + await svc.create_session(app_name="app", + user_id="user", + session_id="s1", + state={ + "sk": "sv", + f"{State.APP_PREFIX}ak": "av", + f"{State.USER_PREFIX}uk": "uv", + }) result = await svc.get_session(app_name="app", user_id="user", session_id="s1") assert result.state["sk"] == "sv" assert result.state[f"{State.APP_PREFIX}ak"] == "av" @@ -185,6 +185,7 @@ async def test_get_with_merged_state(self): class TestRedisListSessions: + async def test_list_empty(self): svc = _create_service() result = await svc.list_sessions(app_name="app", user_id="user") @@ -210,8 +211,18 @@ async def test_list_sessions_have_no_events(self): assert s.historical_events == [] await svc.close() + async def test_list_all_users_when_user_id_none(self): + svc = _create_service() + await svc.create_session(app_name="app", user_id="user1", session_id="s1") + await svc.create_session(app_name="app", user_id="user2", session_id="s2") + result = await svc.list_sessions(app_name="app", user_id=None) + ids = sorted(s.id for s in result.sessions) + assert ids == ["s1", "s2"] + await svc.close() + class TestRedisDeleteSession: + async def test_delete_existing(self): svc = _create_service() await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -227,6 +238,7 @@ async def test_delete_nonexistent(self): class TestRedisAppendEvent: + async def test_append_basic(self): svc = _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -264,12 +276,13 @@ async def test_append_with_state_delta(self): async def test_append_does_not_persist_merged_or_temp_state_in_session_json(self): svc = _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id="s1") - event = _make_event(state_delta={ - "session_key": "sv", - f"{State.APP_PREFIX}app_key": "av", - f"{State.USER_PREFIX}user_key": "uv", - f"{State.TEMP_PREFIX}temp_key": "tv", - }) + event = _make_event( + state_delta={ + "session_key": "sv", + f"{State.APP_PREFIX}app_key": "av", + f"{State.USER_PREFIX}user_key": "uv", + f"{State.TEMP_PREFIX}temp_key": "tv", + }) await svc.append_event(session, event) @@ -306,6 +319,7 @@ async def test_append_persists_filtered_active_and_historical_events(self): class TestRedisUpdateSession: + async def test_update_existing(self): svc = _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -323,6 +337,7 @@ async def test_update_nonexistent(self): class TestRedisRefreshTtl: + async def test_refresh_ttl_disabled(self): svc = _create_service(config=_make_config()) mock_redis = MagicMock() @@ -340,6 +355,7 @@ async def test_refresh_ttl_enabled(self): class TestRedisClose: + async def test_close(self): svc = _create_service() await svc.close() diff --git a/tests/sessions/test_sql_session_service.py b/tests/sessions/test_sql_session_service.py index 30b3df1e..e1730ec6 100644 --- a/tests/sessions/test_sql_session_service.py +++ b/tests/sessions/test_sql_session_service.py @@ -43,8 +43,9 @@ def _make_config(ttl_seconds=0, max_events=max_events, store_historical_events=store_historical_events) if enable_ttl: - config.ttl = SessionServiceConfig.create_ttl_config( - enable=True, ttl_seconds=ttl_seconds, cleanup_interval_seconds=cleanup_interval) + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, + ttl_seconds=ttl_seconds, + cleanup_interval_seconds=cleanup_interval) else: config.clean_ttl_config() return config @@ -81,7 +82,9 @@ async def _create_service(config=None): # SessionStorageEvent — from_event / to_event # --------------------------------------------------------------------------- + class TestSessionStorageEvent: + def test_from_event_basic(self): session = Session(id="s1", app_name="app", user_id="user", save_key="k") event = _make_event(author="user", text="hello world") @@ -125,7 +128,10 @@ def test_to_event_drops_legacy_empty_parts(self): long_running_tool_ids=set(), timestamp=datetime.now(), model_flags=1, - content={"parts": [{}], "role": "model"}, + content={ + "parts": [{}], + "role": "model" + }, ) event = storage_event.to_event() assert event.content is None @@ -157,7 +163,9 @@ def test_long_running_tool_ids_empty(self): # SqlSessionService — create_session # --------------------------------------------------------------------------- + class TestSqlCreateSession: + async def test_create_basic(self): svc = await _create_service() session = await svc.create_session(app_name="app", user_id="user") @@ -174,13 +182,14 @@ async def test_create_with_custom_id(self): async def test_create_with_state(self): svc = await _create_service() - session = await svc.create_session( - app_name="app", user_id="user", session_id="s1", - state={ - "sk": "sv", - f"{State.APP_PREFIX}ak": "av", - f"{State.USER_PREFIX}uk": "uv", - }) + session = await svc.create_session(app_name="app", + user_id="user", + session_id="s1", + state={ + "sk": "sv", + f"{State.APP_PREFIX}ak": "av", + f"{State.USER_PREFIX}uk": "uv", + }) assert session.state["sk"] == "sv" assert session.state[f"{State.APP_PREFIX}ak"] == "av" assert session.state[f"{State.USER_PREFIX}uk"] == "uv" @@ -205,7 +214,9 @@ async def test_create_with_whitespace_id(self): # SqlSessionService — get_session # --------------------------------------------------------------------------- + class TestSqlGetSession: + async def test_get_existing(self): svc = await _create_service() await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -242,13 +253,14 @@ async def test_get_with_num_recent_events(self): async def test_get_with_merged_state(self): svc = await _create_service() - await svc.create_session( - app_name="app", user_id="user", session_id="s1", - state={ - "sk": "sv", - f"{State.APP_PREFIX}ak": "av", - f"{State.USER_PREFIX}uk": "uv", - }) + await svc.create_session(app_name="app", + user_id="user", + session_id="s1", + state={ + "sk": "sv", + f"{State.APP_PREFIX}ak": "av", + f"{State.USER_PREFIX}uk": "uv", + }) result = await svc.get_session(app_name="app", user_id="user", session_id="s1") assert result.state["sk"] == "sv" assert result.state[f"{State.APP_PREFIX}ak"] == "av" @@ -260,7 +272,9 @@ async def test_get_with_merged_state(self): # SqlSessionService — list_sessions # --------------------------------------------------------------------------- + class TestSqlListSessions: + async def test_list_empty(self): svc = await _create_service() result = await svc.list_sessions(app_name="app", user_id="user") @@ -286,12 +300,23 @@ async def test_list_sessions_have_no_events(self): assert s.historical_events == [] await svc.close() + async def test_list_all_users_when_user_id_none(self): + svc = await _create_service() + await svc.create_session(app_name="app", user_id="user1", session_id="s1") + await svc.create_session(app_name="app", user_id="user2", session_id="s2") + result = await svc.list_sessions(app_name="app", user_id=None) + ids = sorted(s.id for s in result.sessions) + assert ids == ["s1", "s2"] + await svc.close() + # --------------------------------------------------------------------------- # SqlSessionService — delete_session # --------------------------------------------------------------------------- + class TestSqlDeleteSession: + async def test_delete_existing(self): svc = await _create_service() await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -305,7 +330,9 @@ async def test_delete_existing(self): # SqlSessionService — append_event # --------------------------------------------------------------------------- + class TestSqlAppendEvent: + async def test_append_basic(self): svc = await _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -387,7 +414,9 @@ async def test_append_persists_historical_events(self): # SqlSessionService — update_session # --------------------------------------------------------------------------- + class TestSqlUpdateSession: + async def test_update_existing(self): svc = await _create_service() session = await svc.create_session(app_name="app", user_id="user", session_id="s1") @@ -410,7 +439,9 @@ async def test_update_nonexistent(self): # SqlSessionService — cleanup # --------------------------------------------------------------------------- + class TestSqlCleanup: + def test_no_cleanup_task_when_disabled(self): config = _make_config() with patch("trpc_agent_sdk.sessions._sql_session_service.SqlStorage"): diff --git a/trpc_agent_sdk/abc/_session_service.py b/trpc_agent_sdk/abc/_session_service.py index 15107b2e..419fac06 100644 --- a/trpc_agent_sdk/abc/_session_service.py +++ b/trpc_agent_sdk/abc/_session_service.py @@ -92,8 +92,19 @@ async def get_session( """Gets a session.""" @abstractmethod - async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsResponse: - """Lists all the sessions.""" + async def list_sessions( + self, + *, + app_name: str, + user_id: Optional[str] = None, + ) -> ListSessionsResponse: + """Lists all the sessions. + + Args: + app_name: the name of the app. + user_id: the id of the user. When None, lists sessions across all + users for the given app. + """ @abstractmethod async def delete_session(self, *, app_name: str, user_id: str, session_id: str) -> None: diff --git a/trpc_agent_sdk/evaluation/_eval_session_service.py b/trpc_agent_sdk/evaluation/_eval_session_service.py index d8c97b82..d9e231db 100644 --- a/trpc_agent_sdk/evaluation/_eval_session_service.py +++ b/trpc_agent_sdk/evaluation/_eval_session_service.py @@ -71,7 +71,7 @@ async def get_session( ) @override - async def list_sessions(self, *, app_name: str, user_id: str): + async def list_sessions(self, *, app_name: str, user_id: Optional[str] = None): return await self._inner.list_sessions(app_name=app_name, user_id=user_id) @override diff --git a/trpc_agent_sdk/sessions/_in_memory_session_service.py b/trpc_agent_sdk/sessions/_in_memory_session_service.py index a2c542ed..567a52d1 100644 --- a/trpc_agent_sdk/sessions/_in_memory_session_service.py +++ b/trpc_agent_sdk/sessions/_in_memory_session_service.py @@ -183,26 +183,32 @@ async def get_session( return self._merge_state(app_state, user_state, copied_session) @override - async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsResponse: + async def list_sessions(self, *, app_name: str, user_id: Optional[str] = None) -> ListSessionsResponse: empty_response = ListSessionsResponse() - if app_name not in self._sessions: + app_sessions = self._sessions.get(app_name) + if not app_sessions: return empty_response - if user_id not in self._sessions[app_name]: + + if user_id is None: + user_sessions = app_sessions + elif user_id in app_sessions: + user_sessions = {user_id: app_sessions[user_id]} + else: return empty_response + app_state = self._get_app_state(app_name) sessions_without_events = [] - for session_id in self._sessions[app_name][user_id].keys(): - session = self._get_session(app_name, user_id, session_id) - if session is None: - continue - - copied_session = copy.deepcopy(session) - copied_session.events = [] - copied_session.historical_events = [] - app_state = self._get_app_state(app_name) - user_state = self._get_user_state(app_name, user_id) - copied_session = self._merge_state(app_state, user_state, copied_session) - sessions_without_events.append(copied_session) + for uid, session_dict in user_sessions.items(): + user_state = self._get_user_state(app_name, uid) + for session_id in session_dict.keys(): + session = self._get_session(app_name, uid, session_id) + if session is None: + continue + + copied_session = copy.deepcopy(session) + copied_session.events = [] + copied_session.historical_events = [] + sessions_without_events.append(self._merge_state(app_state, user_state, copied_session)) return ListSessionsResponse(sessions=sessions_without_events) @override diff --git a/trpc_agent_sdk/sessions/_redis_session_service.py b/trpc_agent_sdk/sessions/_redis_session_service.py index 01f77182..8751a75f 100644 --- a/trpc_agent_sdk/sessions/_redis_session_service.py +++ b/trpc_agent_sdk/sessions/_redis_session_service.py @@ -36,6 +36,24 @@ from ._utils import user_state_key +def _session_key_prefix(app_name: str, user_id: Optional[str] = None) -> str: + """Generate a Redis key prefix for listing sessions. + + When user_id is None, the prefix matches sessions across all users for the + given app; otherwise it is scoped to the specific user. + + Args: + app_name: Application name + user_id: Optional user identifier + + Returns: + Formatted session key prefix with a trailing wildcard. + """ + if user_id is None: + return f"session:{app_name}:*" + return f"session:{app_name}:{user_id}:*" + + class RedisSessionService(BaseSessionService): """A Redis implementation of the session service. @@ -129,18 +147,17 @@ async def get_session( return self._merge_state(app_state, user_state, session) @override - async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsResponse: + async def list_sessions(self, *, app_name: str, user_id: Optional[str] = None) -> ListSessionsResponse: async with self._redis_storage.create_db_session() as redis_session: - pattern = session_key(app_name, user_id, "*") + pattern = _session_key_prefix(app_name, user_id) command = RedisCommand(method='keys', args=(pattern, )) keys = await self._redis_storage.execute_command(redis_session, command) if not keys: return ListSessionsResponse() - # Get app and user state once for all sessions + # Get app state once for all sessions app_state = await self._get_app_state(redis_session, app_name) - user_state = await self._get_user_state(redis_session, app_name, user_id) sessions_without_events = [] for key in keys: @@ -150,6 +167,7 @@ async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsRes storage_session.events = [] storage_session.historical_events = [] # Merge state + user_state = await self._get_user_state(redis_session, app_name, storage_session.user_id) storage_session = self._merge_state(app_state, user_state, storage_session) sessions_without_events.append(storage_session) diff --git a/trpc_agent_sdk/sessions/_sql_session_service.py b/trpc_agent_sdk/sessions/_sql_session_service.py index d5ad5a08..4333ffeb 100644 --- a/trpc_agent_sdk/sessions/_sql_session_service.py +++ b/trpc_agent_sdk/sessions/_sql_session_service.py @@ -489,15 +489,17 @@ async def get_session( return self.filter_events(session) @override - async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsResponse: + async def list_sessions(self, *, app_name: str, user_id: Optional[str] = None) -> ListSessionsResponse: async with self._sql_storage.create_db_session() as sql_session: - filters = [StorageSession.app_name == app_name, StorageSession.user_id == user_id] + filters = [StorageSession.app_name == app_name] + if user_id is not None: + filters.append(StorageSession.user_id == user_id) conditions = SqlCondition(filters=filters) - session_key = SqlKey(key=(app_name, user_id), storage_cls=StorageSession) + session_key = SqlKey(key=(app_name, user_id) if user_id is not None else (app_name, ), + storage_cls=StorageSession) results: List[StorageSession] = await self._sql_storage.query(sql_session, session_key, conditions) app_state = await self._get_app_state(sql_session, app_name) - user_state = await self._get_user_state(sql_session, app_name, user_id) sessions = [] for storage_session in results: @@ -508,6 +510,7 @@ async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsRes storage_session.events = [] + user_state = await self._get_user_state(sql_session, app_name, storage_session.user_id) merged_state = merge_state( StateStorageEntry(app_state_delta=app_state, user_state_delta=user_state, From 4336564b9a0c0983ad479711a6d70eaf803f0b51 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Thu, 9 Jul 2026 10:03:30 +0800 Subject: [PATCH 09/26] docs: add docs index (#146) --- .github/workflows/docs.yml | 54 +++++++++++++++++++ docs/mkdocs/en/index.md | 21 ++++++++ docs/mkdocs/index.md | 23 ++++++++ docs/mkdocs/zh/index.md | 21 ++++++++ mkdocs.yml | 105 +++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/mkdocs/en/index.md create mode 100644 docs/mkdocs/index.md create mode 100644 docs/mkdocs/zh/index.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..2a97f80d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,54 @@ +name: Docs + +on: + push: + branches: + - main + paths: + - "docs/mkdocs/**" + - "mkdocs.yml" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install MkDocs + run: python -m pip install --upgrade pip mkdocs + + - name: Build documentation + run: mkdocs build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/mkdocs/en/index.md b/docs/mkdocs/en/index.md new file mode 100644 index 00000000..3110ecb1 --- /dev/null +++ b/docs/mkdocs/en/index.md @@ -0,0 +1,21 @@ +# English Documentation + +Welcome to the English documentation for tRPC-Agent-Python. + +## Getting Started + +- [LLM Agent](./llm_agent.md): Build a general-purpose LLM-powered agent. +- [Model Invocation](./model.md): Configure OpenAI, Anthropic, and LiteLLM models. +- [Tools](./tool.md): Add function tools, file tools, MCP tools, and agent tools. +- [Skills](./skill.md): Package reusable workflows with `SKILL.md`. +- [Code Executor](./code_executor.md): Execute code through local or sandboxed runtimes. + +## Core Capabilities + +- [Session](./session.md): Manage conversation events and state. +- [Memory](./memory.md): Store and retrieve long-term memories. +- [Knowledge](./knowledge.md): Build RAG workflows with LangChain components. +- [Multi-Agent](./multi_agents.md): Compose agents for complex workflows. +- [Evaluation](./evaluation.md): Evaluate agent behavior and response quality. + +For source code and examples, see the [GitHub repository](https://github.com/trpc-group/trpc-agent-python). diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md new file mode 100644 index 00000000..c916be92 --- /dev/null +++ b/docs/mkdocs/index.md @@ -0,0 +1,23 @@ +# tRPC-Agent-Python Documentation + +tRPC-Agent-Python is a production-grade Agent framework deeply integrated with the Python AI ecosystem. It provides an end-to-end foundation for agent building, orchestration, tool integration, session and long-term memory, service deployment, and observability. + +Choose a language to get started: + +- [English Documentation](./en/) +- [中文文档](./zh/) + +## Quick Links + +- [LLM Agent](./en/llm_agent.md) +- [Model Invocation](./en/model.md) +- [Tools](./en/tool.md) +- [Skills](./en/skill.md) +- [Code Executor](./en/code_executor.md) +- [Session](./en/session.md) +- [Memory](./en/memory.md) + +## Project + +- [GitHub Repository](https://github.com/trpc-group/trpc-agent-python) +- [PyPI Package](https://pypi.org/project/trpc-agent-py/) diff --git a/docs/mkdocs/zh/index.md b/docs/mkdocs/zh/index.md new file mode 100644 index 00000000..fb2feccc --- /dev/null +++ b/docs/mkdocs/zh/index.md @@ -0,0 +1,21 @@ +# 中文文档 + +欢迎阅读 tRPC-Agent-Python 中文文档。 + +## 快速开始 + +- [LLM Agent](./llm_agent.md):构建通用 LLM Agent。 +- [模型调用](./model.md):配置 OpenAI、Anthropic 和 LiteLLM 模型。 +- [工具](./tool.md):接入函数工具、文件工具、MCP 工具和 Agent 工具。 +- [Skills](./skill.md):通过 `SKILL.md` 封装可复用工作流。 +- [代码执行器](./code_executor.md):通过本地或沙箱运行时代码执行。 + +## 核心能力 + +- [Session](./session.md):管理会话事件与状态。 +- [Memory](./memory.md):存储和检索长期记忆。 +- [Knowledge](./knowledge.md):基于 LangChain 组件构建 RAG 工作流。 +- [多 Agent](./multi_agents.md):编排多个 Agent 完成复杂任务。 +- [Evaluation](./evaluation.md):评测 Agent 行为和回复质量。 + +源码与示例请参考 [GitHub 仓库](https://github.com/trpc-group/trpc-agent-python)。 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..ae69b587 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,105 @@ +site_name: tRPC-Agent-Python +site_description: Python LLM agent framework with multi-model support, tool calling, session management, and service integrations. +site_url: https://trpc-group.github.io/trpc-agent-python/ +repo_url: https://github.com/trpc-group/trpc-agent-python +repo_name: trpc-group/trpc-agent-python +docs_dir: docs/mkdocs +site_dir: site + +theme: + name: readthedocs + locale: en + +markdown_extensions: + - admonition + - fenced_code + - tables + - toc: + permalink: true + +nav: + - Home: index.md + - English: + - Overview: en/index.md + - Agent: + - LLM Agent: en/llm_agent.md + - Custom Agent: en/custom_agent.md + - LangGraph Agent: en/langgraph_agent.md + - Claude Agent: en/claude_agent.md + - Multi-Agent: en/multi_agents.md + - Team: en/team.md + - Graph: en/graph.md + - Sub Agent: en/sub_agent.md + - Model: en/model.md + - Tools: + - Tool: en/tool.md + - Code Executor: en/code_executor.md + - Skills: en/skill.md + - Session & Memory: + - Session: en/session.md + - SQL Session: en/session_sql.md + - Redis Session: en/session_redis.md + - Session Summary: en/session_summary.md + - Memory: en/memory.md + - Knowledge: + - Knowledge: en/knowledge.md + - Document Loader: en/knowledge_document_loader.md + - Text Splitter: en/knowledge_text_splitter.md + - Embedder: en/knowledge_embedder.md + - Vector Store: en/knowledge_vectorstore.md + - Retrievers: en/knowledge_retrievers.md + - Prompt Template: en/knowledge_prompt_template.md + - Custom Components: en/knowledge_custom_components.md + - Evaluation & Optimization: + - Evaluation: en/evaluation.md + - Optimization: en/optimization.md + - Integrations: + - A2A: en/a2a.md + - AG-UI: en/agui.md + - OpenClaw: en/openclaw.md + - Runtime: + - Filter: en/filter.md + - Human in the Loop: en/human_in_the_loop.md + - Cancellation: en/cancel.md + - 中文: + - 概览: zh/index.md + - Agent: + - LLM Agent: zh/llm_agent.md + - 自定义 Agent: zh/custom_agent.md + - LangGraph Agent: zh/langgraph_agent.md + - Claude Agent: zh/claude_agent.md + - 多 Agent: zh/multi_agents.md + - Team: zh/team.md + - Graph: zh/graph.md + - Sub Agent: zh/sub_agent.md + - 模型: zh/model.md + - 工具: + - 工具: zh/tool.md + - 代码执行器: zh/code_executor.md + - Skills: zh/skill.md + - Session 与 Memory: + - Session: zh/session.md + - SQL Session: zh/session_sql.md + - Redis Session: zh/session_redis.md + - Session Summary: zh/session_summary.md + - Memory: zh/memory.md + - Knowledge: + - Knowledge: zh/knowledge.md + - 文档加载器: zh/knowledge_document_loader.md + - 文本切分: zh/knowledge_text_splitter.md + - Embedder: zh/knowledge_embedder.md + - Vector Store: zh/knowledge_vectorstore.md + - Retrievers: zh/knowledge_retrievers.md + - Prompt Template: zh/knowledge_prompt_template.md + - 自定义组件: zh/knowledge_custom_components.md + - 评测与优化: + - Evaluation: zh/evaluation.md + - Optimization: zh/optimization.md + - 集成: + - A2A: zh/a2a.md + - AG-UI: zh/agui.md + - OpenClaw: zh/openclaw.md + - 运行时: + - Filter: zh/filter.md + - Human in the Loop: zh/human_in_the_loop.md + - Cancellation: zh/cancel.md From 8fdf6d8b6b6e2f5f46c4e7df706b3921086d572b Mon Sep 17 00:00:00 2001 From: Phil-Fan Date: Thu, 9 Jul 2026 13:31:22 +0800 Subject: [PATCH 10/26] fix(docs): correct README installation command syntax - Fix pip install command by enclosing extras in double quotes - Remove extra spaces and normalize casing in extra packages list - Ensure command works correctly for installing optional dependencies --- README.zh_CN.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.zh_CN.md b/README.zh_CN.md index b5619c2a..ca4c5f3b 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -84,10 +84,9 @@ pip install trpc-agent-py 按需安装扩展能力: ```bash -pip install trpc-agent-py[a2a,ag-ui,knowledge,agent-claude,mem0, Mempalace, langfuse] +pip install "trpc-agent-py[a2a,ag-ui,knowledge,agent-claude,mem0,mempalace,langfuse]" ``` - ### 开发天气查询Agent ```python From 89c44cebc3b16a6d6cc84fff93012ea155641ca2 Mon Sep 17 00:00:00 2001 From: Phil-Fan Date: Thu, 9 Jul 2026 13:58:04 +0800 Subject: [PATCH 11/26] fix(docs): fix pip install command formatting - Removed extra spaces within the optional dependencies list - Added quotes around the dependency list to ensure proper bash parsing - Corrected capitalization for consistency in package names --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 279e2169..b9933cab 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ pip install trpc-agent-py Install optional capabilities as needed: ```bash -pip install trpc-agent-py[a2a,ag-ui,knowledge,agent-claude,mem0, Mempalace, langfuse] +pip install "trpc-agent-py[a2a,ag-ui,knowledge,agent-claude,mem0,mempalace,langfuse]" ``` ### Develop Weather Agent From bbd07ebe7f56732c2f640191d232ebc34b0a5d5d Mon Sep 17 00:00:00 2001 From: bochencwx Date: Fri, 10 Jul 2026 11:34:10 +0800 Subject: [PATCH 12/26] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=AD=90Agent=E6=94=AF=E6=8C=81=E5=9C=A8=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E4=BA=A7=E7=94=9F=E7=9A=84=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=BD=AC=E5=8F=91=E5=88=B0=E7=88=B6=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E7=9A=84=E4=BA=8B=E4=BB=B6=E6=B5=81=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/sub_agent.md | 25 ++ docs/mkdocs/zh/sub_agent.md | 23 ++ examples/dynamic_subagent/agent/agent.py | 9 +- examples/dynamic_subagent/run_agent.py | 45 ++- examples/spawn_subagent/agent/agent.py | 19 +- examples/spawn_subagent/run_agent.py | 44 +++ .../sub_agent/test_dynamic_sub_agent_tool.py | 51 +++ .../sub_agent/test_spawn_sub_agent_tool.py | 53 +++ .../test_subagent_event_forwarding.py | 317 ++++++++++++++++++ .../sub_agent/_dynamic_sub_agent_tool.py | 90 ++++- trpc_agent_sdk/agents/sub_agent/_runner.py | 156 ++++++++- .../agents/sub_agent/_spawn_sub_agent_tool.py | 89 ++++- .../agents/sub_agent/_sub_agent_config.py | 6 + 13 files changed, 885 insertions(+), 42 deletions(-) create mode 100644 tests/agents/sub_agent/test_subagent_event_forwarding.py diff --git a/docs/mkdocs/en/sub_agent.md b/docs/mkdocs/en/sub_agent.md index 00e8d79f..f9ebce63 100644 --- a/docs/mkdocs/en/sub_agent.md +++ b/docs/mkdocs/en/sub_agent.md @@ -21,6 +21,19 @@ A **short-lived sub-agent** is a natural fit for these problems: a fresh context The difference is *who defines the role*: the developer (Spawn) or the LLM (Dynamic). +### How this differs from other multi-agent mechanisms + +The framework already offers several ways to compose agents (see [Multi Agents](multi_agents.md)). Spawned Sub-Agents solve a different problem: + +| Mechanism | Agents involved | Who decides when to invoke | Context | Typical use | +| --- | --- | --- | --- | --- | +| **Chain / Parallel / Cycle Agent** | **Pre-built** fixed agent instances | **Deterministic** orchestration — run in list order / in parallel / in a loop, regardless of input | Each agent independent | Fixed multi-step workflows | +| **Sub Agents (transfer)** | Pre-registered agents | Parent **transfers control** at runtime; the sub-agent then takes over the conversation | Shared session | **Hand off** the whole conversation to a better-suited agent | +| **AgentTool** | Wraps **an existing agent instance** as a tool | Parent LLM calls it on demand | Shares/syncs state & artifacts back to parent | Reuse a **specific, already-built** agent | +| **Spawned Sub-Agents** | **Created on the fly** per call, destroyed after | Parent LLM calls it on demand | **Strictly isolated**: fresh ephemeral session, history/state not shared by default | Delegate a **one-off** subtask while keeping the parent context clean | + +In one line: **Chain/Parallel/Cycle** deterministically orchestrate a fixed set of agents; **transfer** hands the conversation off; **AgentTool** reuses one existing agent as a tool; while **Spawned Sub-Agents** create an **isolated, short-lived** sub-agent on the spot for a single task and discard it afterward — the emphasis is on **on-demand runtime creation** and **context isolation**, not reusing an existing agent or transferring control. + ## Quick Start ```python @@ -173,8 +186,19 @@ class SubAgentConfig: max_turns: int | None = None """Max LLM calls the sub-agent may make. None = unlimited.""" + + forward_events: bool = False + """Whether to forward the sub-agent's execution events to the parent + runner's consumer as progress updates. + + True: the orchestrator can display the sub-agent's execution live (model + output, tool calls, tool results); the parent agent's LLM still receives + only the sub-agent's final result. False (default): the sub-agent runs + silently and only its final result is returned.""" ``` +Forwarded events reach the consumer as progress events; they are **not** written to the parent session and **never** enter the parent agent's LLM context. Consumers identify them via `tool_progress=True` on `event.custom_metadata` and read the execution from `payload` (`author` / `partial` / `content`, plus optional `error` / `usage`). + ## Usage ### SpawnSubAgentTool @@ -270,3 +294,4 @@ orchestrator = LlmAgent( - **Session isolation**: sub-agents run in a fresh ephemeral session. Parent history is not shared by default; opt in via `include_parent_history=True`. - **Nesting**: 1-level hard cap. Sub-agents cannot spawn further sub-agents. - **Result shape**: the sub-agent's final text is returned as the tool result string. +- **Live execution (`forward_events`)**: set `SubAgentConfig(forward_events=True)` to stream the sub-agent's execution to the parent runner's consumer for display. Forwarded events are progress events — they never enter the parent LLM's context, which still receives only the final result. Consumers detect them via `tool_progress=True` on `event.custom_metadata` and read `payload`. See `examples/dynamic_subagent` and `examples/spawn_subagent` for a working consumer. diff --git a/docs/mkdocs/zh/sub_agent.md b/docs/mkdocs/zh/sub_agent.md index 568bbf11..09df29a4 100644 --- a/docs/mkdocs/zh/sub_agent.md +++ b/docs/mkdocs/zh/sub_agent.md @@ -21,6 +21,19 @@ 区别在于**谁定义角色**:开发者(Spawn)还是 LLM(Dynamic)。 +### 与框架其他多 agent 机制的区别 + +框架已有几种组合 agent 的方式(详见 [Multi Agents](multi_agents.md))。Spawned Sub-Agents 与它们解决的是不同问题: + +| 机制 | 参与的 agent | 谁决定何时调用 | 上下文 | 典型用途 | +| --- | --- | --- | --- | --- | +| **Chain / Parallel / Cycle Agent** | **预先构建**的固定 agent 实例 | **确定性**编排——按列表顺序/并行/循环执行,与输入无关 | 各 agent 独立 | 固定的多步工作流 | +| **Sub Agents(transfer)** | 预先注册的 agent | 父 agent 运行时**转移控制权**,之后由子 agent 接管对话 | 共享同一会话 | 把整段对话**移交**给更合适的 agent | +| **AgentTool** | 把**某个已有 agent 实例**包成工具 | 父 LLM 按需调用 | 会共享/同步 state 与 artifact 回父 | 复用一个**具体的、已存在的** agent | +| **Spawned Sub-Agents** | **调用时临时创建**、用完即销毁 | 父 LLM 按需调用 | **严格隔离**:全新临时会话,默认不共享历史/state | 委派**一次性**子任务,保持父上下文干净 | + +一句话概括:**Chain/Parallel/Cycle** 是"确定性编排一组固定 agent";**transfer** 是"把对话交出去";**AgentTool** 是"把一个已有 agent 当工具复用";而 **Spawned Sub-Agents** 是"为单次任务**现场造一个隔离的、短命的**子 agent,跑完就丢"——强调的是**运行时按需创建**与**上下文隔离**,而非复用既有 agent 或转移控制。 + ## Quick Start ```python @@ -173,8 +186,17 @@ class SubAgentConfig: max_turns: int | None = None """子 agent 最多可发起的 LLM 调用次数。None = 不限制。""" + + forward_events: bool = False + """是否将子 agent 的执行事件转发给父 runner 的消费者,作为进度更新。 + + True:编排层可实时展示子 agent 的执行(模型输出、工具调用、工具结果), + 父 agent 的 LLM 仍只收到子 agent 的最终结果。 + False(默认):子 agent 静默执行,只回传最终结果。""" ``` +转发的事件以进度事件形式到达消费者,**不会**写入父会话、也**不会**进入父 agent 的 LLM 上下文;消费者通过 `event.custom_metadata` 上的 `tool_progress=True` 识别它们,并从 `payload` 读取执行内容(`author` / `partial` / `content` / 可选 `error` / `usage`)。 + ## 使用方式 ### SpawnSubAgentTool @@ -270,3 +292,4 @@ orchestrator = LlmAgent( - **会话隔离**:子 agent 在全新临时会话中运行,默认不共享父会话历史。通过 `include_parent_history=True` 可注入。 - **嵌套限制**:1 层硬限,子 agent 无法再次 spawn。 - **结果形态**:子 agent 的最终文本作为 tool result 字符串返回。 +- **实时执行(`forward_events`)**:设置 `SubAgentConfig(forward_events=True)` 可将子 agent 的执行流转发给父 runner 的消费者用于展示。转发事件为进度事件——它们不会进入父 agent 的 LLM 上下文(父仍只收到最终结果)。消费者通过 `event.custom_metadata` 上的 `tool_progress=True` 识别它们并读取 `payload`。可运行示例见 `examples/dynamic_subagent` 与 `examples/spawn_subagent`。 diff --git a/examples/dynamic_subagent/agent/agent.py b/examples/dynamic_subagent/agent/agent.py index af661a84..fc8e10b1 100644 --- a/examples/dynamic_subagent/agent/agent.py +++ b/examples/dynamic_subagent/agent/agent.py @@ -45,7 +45,12 @@ def create_minimal_agent() -> LlmAgent: temperature=0.7, max_output_tokens=2000, ), - tools=workspace_tools + [DynamicSubAgentTool()], + tools=workspace_tools + [ + DynamicSubAgentTool( + # Stream the sub-agent's execution to the parent consumer. + agent_config=SubAgentConfig(forward_events=True), + ), + ], ) @@ -69,6 +74,8 @@ def create_bounded_agent() -> LlmAgent: temperature=0.3, max_output_tokens=1000, ), + # Stream the sub-agent's execution to the parent consumer. + forward_events=True, ), ), ], diff --git a/examples/dynamic_subagent/run_agent.py b/examples/dynamic_subagent/run_agent.py index d0350007..18031dd5 100644 --- a/examples/dynamic_subagent/run_agent.py +++ b/examples/dynamic_subagent/run_agent.py @@ -33,7 +33,6 @@ def _truncate(text: str, max_len: int = 200) -> str: """Truncate long tool output for display.""" - return text if not isinstance(text, str): text = str(text) if len(text) <= max_len: @@ -41,6 +40,38 @@ def _truncate(text: str, max_len: int = 200) -> str: return text[:max_len] + f"\n... (truncated, total {len(text)} chars)" +def _print_subagent_progress(payload: dict) -> None: + """Render one forwarded sub-agent execution event. + + ``payload`` is a :class:`SubAgentProgress` dict: ``author`` / ``partial``, + the framework-native ``content`` dump (``parts`` with ``function_call`` / + ``function_response`` / ``text`` / ``thought``), and optional ``error`` / + ``usage``. Indented under the parent output so the sub-agent's steps are + visually distinct from the orchestrator's. + """ + name = payload.get("author") or "subagent" + # Errors first — always surface them, even on an otherwise-partial event. + err = payload.get("error") + if err: + print(f"\n \U0001F9E9 [{name}] !! error {err.get('code')}: {err.get('message')}") + if payload.get("partial"): + # Skip streaming text deltas to keep the demo output readable; the + # non-partial steps below already summarize the sub-agent's work. + return + parts = (payload.get("content") or {}).get("parts") or [] + has_calls = any(p.get("function_call") or p.get("function_response") for p in parts) + for p in parts: + fc = p.get("function_call") + if fc: + print(f"\n \U0001F9E9 [{name}] -> tool {fc.get('name')}({_truncate(fc.get('args'))})") + fr = p.get("function_response") + if fr: + print(f" \U0001F9E9 [{name}] <- {_truncate(fr.get('response'))}") + text = p.get("text") + if text and not p.get("thought") and not has_calls: + print(f"\n \U0001F9E9 [{name}] {_truncate(text)}") + + _QUERIES = { "minimal": [ # Simple task: orchestrator may call word_count directly. @@ -93,6 +124,18 @@ async def run_demo(mode: str): session_id=current_session_id, new_message=user_content, ): + # Forwarded sub-agent execution events (SubAgentConfig + # forward_events=True). These are partial progress events carrying + # the sub-agent's own steps under custom_metadata.payload; they + # never reach the parent LLM's context. This demo registers only the + # sub-agent tool, so tool_progress alone identifies them; an app with + # several progress tools would also branch on meta["tool_name"]. + meta = event.custom_metadata or {} + payload = meta.get("payload") + if meta.get("tool_progress") and isinstance(payload, dict): + _print_subagent_progress(payload) + continue + if event.content and event.content.parts and event.author != "user": if event.partial: for part in event.content.parts: diff --git a/examples/spawn_subagent/agent/agent.py b/examples/spawn_subagent/agent/agent.py index e5e7ebc7..7fd99360 100644 --- a/examples/spawn_subagent/agent/agent.py +++ b/examples/spawn_subagent/agent/agent.py @@ -25,6 +25,7 @@ from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.agents.sub_agent import SubAgentConfig from trpc_agent_sdk.tools import SpawnSubAgentTool from trpc_agent_sdk.models import LLMModel from trpc_agent_sdk.models import OpenAIModel @@ -52,7 +53,11 @@ def create_default_agent() -> LlmAgent: description="Coding assistant with spawn_subagent in zero-config mode.", model=_create_model(), instruction=INSTRUCTION, - tools=[ReadTool(), GlobTool(), GrepTool(), SpawnSubAgentTool()], + tools=[ReadTool(), GlobTool(), GrepTool(), + SpawnSubAgentTool( + # Stream the sub-agent's execution to the parent consumer. + agent_config=SubAgentConfig(forward_events=True), + )], ) @@ -88,7 +93,11 @@ def create_code_agent() -> LlmAgent: instruction=INSTRUCTION, tools=[ ReadTool(), GlobTool(), GrepTool(), - SpawnSubAgentTool(agents=[_SECURITY_AUDITOR, EXPLORE_AGENT, PLAN_AGENT]), + SpawnSubAgentTool( + agents=[_SECURITY_AUDITOR, EXPLORE_AGENT, PLAN_AGENT], + # Stream the sub-agent's execution to the parent consumer. + agent_config=SubAgentConfig(forward_events=True), + ), ], ) @@ -109,7 +118,11 @@ def create_md_agent() -> LlmAgent: instruction=INSTRUCTION, tools=[ ReadTool(), GlobTool(), GrepTool(), - SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT], agent_paths=[_AGENTS_PATH]), + SpawnSubAgentTool( + agents=[EXPLORE_AGENT, PLAN_AGENT], agent_paths=[_AGENTS_PATH], + # Stream the sub-agent's execution to the parent consumer. + agent_config=SubAgentConfig(forward_events=True), + ), ], ) diff --git a/examples/spawn_subagent/run_agent.py b/examples/spawn_subagent/run_agent.py index ca72b96a..90dc2519 100644 --- a/examples/spawn_subagent/run_agent.py +++ b/examples/spawn_subagent/run_agent.py @@ -45,6 +45,38 @@ def _truncate(text: str, max_len: int = 200) -> str: return text[:max_len] + f"\n... (truncated, total {len(text)} chars)" +def _print_subagent_progress(payload: dict) -> None: + """Render one forwarded sub-agent execution event. + + ``payload`` is a :class:`SubAgentProgress` dict: ``author`` / ``partial``, + the framework-native ``content`` dump (``parts`` with ``function_call`` / + ``function_response`` / ``text`` / ``thought``), and optional ``error`` / + ``usage``. Indented under the parent output so the sub-agent's steps are + visually distinct from the assistant's. + """ + name = payload.get("author") or "subagent" + # Errors first — always surface them, even on an otherwise-partial event. + err = payload.get("error") + if err: + print(f"\n \U0001F9E9 [{name}] !! error {err.get('code')}: {err.get('message')}") + if payload.get("partial"): + # Skip streaming text deltas to keep the demo output readable; the + # non-partial steps below already summarize the sub-agent's work. + return + parts = (payload.get("content") or {}).get("parts") or [] + has_calls = any(p.get("function_call") or p.get("function_response") for p in parts) + for p in parts: + fc = p.get("function_call") + if fc: + print(f"\n \U0001F9E9 [{name}] -> tool {fc.get('name')}({_truncate(fc.get('args'))})") + fr = p.get("function_response") + if fr: + print(f" \U0001F9E9 [{name}] <- {_truncate(fr.get('response'))}") + text = p.get("text") + if text and not p.get("thought") and not has_calls: + print(f"\n \U0001F9E9 [{name}] {_truncate(text)}") + + # Queries per mode — simple tasks (parent handles directly) vs complex # tasks (delegated to a sub-agent). _SHARED_AGENT_QUERIES = [ @@ -111,6 +143,18 @@ async def run_demo(mode: str): session_id=current_session_id, new_message=user_content, ): + # Forwarded sub-agent execution events (SubAgentConfig + # forward_events=True). These are partial progress events carrying + # the sub-agent's own steps under custom_metadata.payload; they + # never reach the parent LLM's context. This demo registers only the + # sub-agent tool, so tool_progress alone identifies them; an app with + # several progress tools would also branch on meta["tool_name"]. + meta = event.custom_metadata or {} + payload = meta.get("payload") + if meta.get("tool_progress") and isinstance(payload, dict): + _print_subagent_progress(payload) + continue + if event.content and event.content.parts and event.author != "user": if event.partial: for part in event.content.parts: diff --git a/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py b/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py index a48f0d87..6d25450c 100644 --- a/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py +++ b/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py @@ -39,6 +39,57 @@ def test_constructor_with_config() -> None: def test_constructor_skip_summarization() -> None: t = DynamicSubAgentTool(skip_summarization=True) assert t._skip_summarization is True + # Exposed as a property for the progress-streaming execution path. + assert t.skip_summarization is True + + +def test_is_progress_streaming_default_off() -> None: + """Without forward_events, the tool runs on the non-streaming path.""" + assert DynamicSubAgentTool().is_progress_streaming is False + assert DynamicSubAgentTool(agent_config=SubAgentConfig()).is_progress_streaming is False + + +def test_is_progress_streaming_on_when_forwarding() -> None: + t = DynamicSubAgentTool(agent_config=SubAgentConfig(forward_events=True)) + assert t.is_progress_streaming is True + + +@pytest.mark.asyncio +async def test_run_streaming_empty_prompt_yields_error() -> None: + """run_streaming surfaces the prompt validation error as its only value.""" + t = DynamicSubAgentTool(agent_config=SubAgentConfig(forward_events=True)) + ctx = MagicMock() + yielded = [v async for v in t.run_streaming(tool_context=ctx, args={"prompt": " "})] + assert len(yielded) == 1 + assert yielded[0]["status"] == "error" + assert "prompt" in yielded[0]["message"] + + +@pytest.mark.asyncio +async def test_run_streaming_forwards_projections_then_result() -> None: + """run_streaming delegates to run_subagent_streaming, yielding its values in order.""" + from unittest.mock import patch + + t = DynamicSubAgentTool(agent_config=SubAgentConfig(forward_events=True)) + ctx = MagicMock() + + async def _fake_stream(**kwargs): + yield {"author": "subagent_dynamic", "partial": True, "content": {"parts": [{"text": "step 1"}]}} + yield "final result" + + with patch( + "trpc_agent_sdk.agents.sub_agent._dynamic_sub_agent_tool.run_subagent_streaming", + _fake_stream, + ): + yielded = [v async for v in t.run_streaming( + tool_context=ctx, + args={"instruction": "You are a helper.", "prompt": "do it"}, + )] + + assert yielded == [ + {"author": "subagent_dynamic", "partial": True, "content": {"parts": [{"text": "step 1"}]}}, + "final result", + ] def test_constructor_custom_name() -> None: diff --git a/tests/agents/sub_agent/test_spawn_sub_agent_tool.py b/tests/agents/sub_agent/test_spawn_sub_agent_tool.py index 6758b428..e71aa3b7 100644 --- a/tests/agents/sub_agent/test_spawn_sub_agent_tool.py +++ b/tests/agents/sub_agent/test_spawn_sub_agent_tool.py @@ -267,3 +267,56 @@ async def test_skip_summarization_sets_event_action() -> None: args={"subagent_type": "nope", "prompt": "hi", "description": "x"}, ) assert ctx.event_actions.skip_summarization is True + + +def test_is_progress_streaming_default_off() -> None: + """Without forward_events, spawn runs on the non-streaming path.""" + assert SpawnSubAgentTool().is_progress_streaming is False + assert SpawnSubAgentTool(agent_config=SubAgentConfig()).is_progress_streaming is False + + +def test_is_progress_streaming_on_when_forwarding() -> None: + t = SpawnSubAgentTool(agent_config=SubAgentConfig(forward_events=True)) + assert t.is_progress_streaming is True + # skip_summarization is exposed as a property for the streaming path. + assert SpawnSubAgentTool(skip_summarization=True).skip_summarization is True + + +@pytest.mark.asyncio +async def test_run_streaming_unknown_type_no_default_yields_error() -> None: + """run_streaming surfaces the resolve error as its only value.""" + t = SpawnSubAgentTool(with_default=False, agent_config=SubAgentConfig(forward_events=True)) + ctx = _make_tool_context() + yielded = [v async for v in t.run_streaming( + tool_context=ctx, + args={"subagent_type": "nope", "prompt": "hi", "description": "x"}, + )] + assert len(yielded) == 1 + assert yielded[0]["status"] == "error" + + +@pytest.mark.asyncio +async def test_run_streaming_forwards_projections_then_result() -> None: + """run_streaming delegates to run_subagent_streaming, yielding its values in order.""" + from unittest.mock import patch + + t = SpawnSubAgentTool(agent_config=SubAgentConfig(forward_events=True)) + ctx = _make_tool_context() + + async def _fake_stream(**kwargs): + yield {"author": "subagent_default", "partial": True, "content": {"parts": [{"text": "step 1"}]}} + yield "final result" + + with patch( + "trpc_agent_sdk.agents.sub_agent._spawn_sub_agent_tool.run_subagent_streaming", + _fake_stream, + ): + yielded = [v async for v in t.run_streaming( + tool_context=ctx, + args={"subagent_type": "default", "prompt": "do it", "description": "x"}, + )] + + assert yielded == [ + {"author": "subagent_default", "partial": True, "content": {"parts": [{"text": "step 1"}]}}, + "final result", + ] diff --git a/tests/agents/sub_agent/test_subagent_event_forwarding.py b/tests/agents/sub_agent/test_subagent_event_forwarding.py new file mode 100644 index 00000000..fc85cdfc --- /dev/null +++ b/tests/agents/sub_agent/test_subagent_event_forwarding.py @@ -0,0 +1,317 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for sub-agent event forwarding (progress-streaming path). + +Covers the projection helper ``_project_subagent_event`` and the streaming +generator ``run_subagent_streaming``, plus the fact that ``run_subagent`` +delegates to it. The Runner is mocked so no real LLM is required — this mirrors +the mocking style in ``test_runner.py``. +""" + +from __future__ import annotations + +from typing import List +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.agents.sub_agent import GENERAL_PURPOSE_AGENT +from trpc_agent_sdk.agents.sub_agent import SubAgentConfig +from trpc_agent_sdk.agents.sub_agent._runner import _project_subagent_event +from trpc_agent_sdk.agents.sub_agent._runner import run_subagent +from trpc_agent_sdk.agents.sub_agent._runner import run_subagent_streaming +from trpc_agent_sdk.exceptions import RunCancelledException +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.models import ModelRegistry +from trpc_agent_sdk.tools import ReadTool +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + + +class MockLLMModel(LLMModel): + @classmethod + def supported_models(cls) -> List[str]: + return [r"test-dynamic-.*"] + + async def _generate_async_impl(self, request, stream=False, ctx=None): + yield LlmResponse(content=None) + + def validate_request(self, request): + pass + + +@pytest.fixture(scope="module", autouse=True) +def _register_mock_model(): + original = ModelRegistry._registry.copy() + ModelRegistry.register(MockLLMModel) + yield + ModelRegistry._registry = original + + +def _parent_ctx_with_model(model: str) -> MagicMock: + parent_ctx = MagicMock() + parent_agent = MagicMock() + parent_agent.model = model + parent_agent.generate_content_config = None + parent_agent.parallel_tool_calls = False + parent_ctx.agent = parent_agent + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + return parent_ctx + + +# --- _project_subagent_event ------------------------------------------------- + + +def test_project_subagent_event_text_and_metadata() -> None: + event = MagicMock() + event.author = "subagent_general_purpose" + event.partial = True + event.error_code = None + event.error_message = None + event.usage_metadata = None + event.content = Content(role="model", parts=[Part.from_text(text="thinking...")]) + + payload = _project_subagent_event(event) + + assert payload["author"] == "subagent_general_purpose" + assert payload["partial"] is True + # content is the framework-native Content dump, not a bespoke shape. + assert payload["content"]["role"] == "model" + assert payload["content"]["parts"][0]["text"] == "thinking..." + assert "error" not in payload + assert "usage" not in payload + + +def test_project_subagent_event_captures_tool_calls_and_responses() -> None: + call_part = Part.from_function_call(name="calculator", args={"expr": "1+1"}) + call_part.function_call.id = "call-1" + resp_part = Part.from_function_response(name="calculator", response={"result": 2}) + resp_part.function_response.id = "call-1" + + event = MagicMock() + event.author = "subagent_general_purpose" + event.partial = False + event.error_code = None + event.error_message = None + event.usage_metadata = None + event.content = Content(role="model", parts=[call_part, resp_part]) + + payload = _project_subagent_event(event) + + parts = payload["content"]["parts"] + assert parts[0]["function_call"] == {"id": "call-1", "args": {"expr": "1+1"}, "name": "calculator"} + assert parts[1]["function_response"] == {"id": "call-1", "name": "calculator", "response": {"result": 2}} + + +def test_project_subagent_event_keeps_thought_in_content() -> None: + """Thought parts are preserved in content (with thought=True) for the consumer to render or hide.""" + thought = Part(text="internal reasoning", thought=True) + visible = Part.from_text(text="answer") + + event = MagicMock() + event.author = "sub" + event.partial = False + event.error_code = None + event.error_message = None + event.usage_metadata = None + event.content = Content(role="model", parts=[thought, visible]) + + payload = _project_subagent_event(event) + parts = payload["content"]["parts"] + assert parts[0] == {"text": "internal reasoning", "thought": True} + assert parts[1]["text"] == "answer" + + +def test_project_subagent_event_no_content() -> None: + event = MagicMock() + event.author = "sub" + event.partial = False + event.error_code = None + event.error_message = None + event.usage_metadata = None + event.content = None + + payload = _project_subagent_event(event) + assert "content" not in payload + assert payload["author"] == "sub" + + +def test_project_subagent_event_surfaces_error() -> None: + """When the event carries an error, it appears under the ``error`` key.""" + event = MagicMock() + event.author = "sub" + event.partial = False + event.error_code = "MAX_TOKENS" + event.error_message = "context length exceeded" + event.usage_metadata = None + event.content = None + + payload = _project_subagent_event(event) + assert payload["error"] == {"code": "MAX_TOKENS", "message": "context length exceeded"} + + +def test_project_subagent_event_no_error_key_when_clean() -> None: + """A clean event omits the ``error`` key entirely (payload stays lean).""" + event = MagicMock() + event.author = "sub" + event.partial = False + event.error_code = None + event.error_message = None + event.usage_metadata = None + event.content = Content(role="model", parts=[Part.from_text(text="ok")]) + + payload = _project_subagent_event(event) + assert "error" not in payload + + +def test_project_subagent_event_captures_usage() -> None: + """Token counts are lifted out of usage_metadata under the ``usage`` key.""" + usage = MagicMock() + usage.prompt_token_count = 120 + usage.candidates_token_count = 45 + usage.total_token_count = 165 + + event = MagicMock() + event.author = "sub" + event.partial = False + event.error_code = None + event.error_message = None + event.usage_metadata = usage + event.content = Content(role="model", parts=[Part.from_text(text="done")]) + + payload = _project_subagent_event(event) + assert payload["usage"] == {"prompt_tokens": 120, "completion_tokens": 45, "total_tokens": 165} + + +# --- run_subagent_streaming helpers ------------------------------------------ + + +def _make_model_event(text: str, *, partial: bool = False) -> MagicMock: + event = MagicMock() + event.content = Content(role="model", parts=[Part.from_text(text=text)]) + event.author = "subagent_general-purpose" + event.partial = partial + event.error_code = None + event.error_message = None + event.usage_metadata = None + event.is_error = MagicMock(return_value=False) + return event + + +def _mock_streaming_runner(event_stream: list) -> MagicMock: + async def _fake_run_async(*args, **kwargs): + for event in event_stream: + yield event + + mock_runner_instance = MagicMock() + mock_runner_instance.run_async = _fake_run_async + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.session_service.append_event = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + return mock_runner_instance + + +# --- run_subagent_streaming -------------------------------------------------- + + +@pytest.mark.asyncio +async def test_streaming_yields_projections_then_result() -> None: + """Each sub-agent event yields a projection dict; the last value is the result.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + events = [_make_model_event("partial", partial=True), _make_model_event("Final answer.")] + mock_runner_instance = _mock_streaming_runner(events) + + with patch("trpc_agent_sdk.runners.Runner", MagicMock(return_value=mock_runner_instance)): + yielded = [v async for v in run_subagent_streaming( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + )] + + # 2 projection dicts (one per event) + 1 final string result. + assert len(yielded) == 3 + assert all(isinstance(v, dict) and "content" in v for v in yielded[:2]) + assert yielded[-1] == "Final answer." + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_streaming_build_error_yields_error_dict_only() -> None: + """A build failure yields a single error dict as the final value (no projections).""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.model = None # force resolve_model to raise + parent_ctx.agent.tools = [] + + yielded = [v async for v in run_subagent_streaming( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + )] + + assert len(yielded) == 1 + assert yielded[0]["status"] == "error" + + +@pytest.mark.asyncio +async def test_streaming_cancelled_final_value() -> None: + """Cancellation surfaces the marker as the final yielded value, not raised.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + mock_runner_instance = _mock_streaming_runner([]) + mock_runner_instance.run_async = MagicMock(side_effect=RunCancelledException()) + + with patch("trpc_agent_sdk.runners.Runner", MagicMock(return_value=mock_runner_instance)): + yielded = [v async for v in run_subagent_streaming( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + )] + + assert yielded[-1] == "[sub-agent cancelled]" + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_streaming_max_turns_note_in_final() -> None: + """max_turns stops streaming and folds the note into the final value.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + events = [_make_model_event("Iteration 1."), _make_model_event("Iteration 2.")] + mock_runner_instance = _mock_streaming_runner(events) + + with patch("trpc_agent_sdk.runners.Runner", MagicMock(return_value=mock_runner_instance)): + yielded = [v async for v in run_subagent_streaming( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + agent_config=SubAgentConfig(max_turns=1), + )] + + assert "[sub-agent stopped: max turns reached]" in yielded[-1] + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_subagent_delegates_to_streaming() -> None: + """run_subagent returns the same final value the streaming generator ends on.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + events = [_make_model_event("partial", partial=True), _make_model_event("Final answer.")] + mock_runner_instance = _mock_streaming_runner(events) + + with patch("trpc_agent_sdk.runners.Runner", MagicMock(return_value=mock_runner_instance)): + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + ) + + assert result == "Final answer." diff --git a/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py b/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py index d1dec7f9..1f0a82f0 100644 --- a/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py +++ b/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py @@ -9,8 +9,11 @@ from __future__ import annotations from typing import Any +from typing import AsyncIterator from typing import List from typing import Optional +from typing import Tuple +from typing import Union from typing_extensions import override from trpc_agent_sdk.context import InvocationContext @@ -23,6 +26,7 @@ from ._archetype import SubAgentArchetype from ._runner import run_subagent +from ._runner import run_subagent_streaming from ._sub_agent_config import SubAgentConfig _DESCRIPTION = ("Run one short-lived sub-agent for a single focused task and return " @@ -86,8 +90,35 @@ def __init__( self._agent_config = agent_config self._skip_summarization = skip_summarization self._expose_tool_selection = expose_tool_selection + # When the config asks to forward the sub-agent's events, this tool + # behaves as a progress-streaming tool (see is_progress_streaming / + # run_streaming). Resolved once at construction because + # is_progress_streaming is read before execution to route the call + # onto the streaming path. + self._forward_events = bool(agent_config is not None and agent_config.forward_events) super().__init__(name=name, description=description or _DESCRIPTION, filters_name=filters_name, filters=filters) + @property + @override + def is_progress_streaming(self) -> bool: + """Route through the progress-streaming path when event forwarding is on. + + Statically determined by ``SubAgentConfig.forward_events`` so + the tools processor can partition this call onto the streaming path + (which drives :meth:`run_streaming`) before execution begins. + """ + return self._forward_events + + @property + def skip_summarization(self) -> bool: + """Whether the streamed sub-agent result is the parent's final answer. + + Read by the progress-streaming execution path (which bypasses + ``_run_async_impl``) to set ``skip_summarization`` on the final + ``function_response`` event. + """ + return self._skip_summarization + @override def _get_declaration(self) -> FunctionDeclaration: properties: dict = { @@ -155,16 +186,12 @@ async def process_request( "or constraints for this run.") llm_request.append_instructions([instruction]) - @override - async def _run_async_impl( - self, - *, - tool_context: InvocationContext, - args: dict[str, Any], - ) -> Any: - if self._skip_summarization: - tool_context.event_actions.skip_summarization = True + def _resolve_call(self, args: dict[str, Any]) -> Union[Tuple[SubAgentArchetype, str, Optional[list]], dict]: + """Parse and validate call args into ``(archetype, prompt, tool_filter)``. + Returns an error dict when ``prompt`` is missing/empty so both the + streaming and non-streaming paths surface the same failure shape. + """ instruction = args.get("instruction") prompt = args.get("prompt") @@ -192,6 +219,23 @@ async def _run_async_impl( instruction=instruction, tools=self._tools, # None=inherit, tuple=fixed set ) + return synthetic, prompt, tool_filter + + @override + async def _run_async_impl( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> Any: + if self._skip_summarization: + tool_context.event_actions.skip_summarization = True + + resolved = self._resolve_call(args) + if isinstance(resolved, dict): + return resolved + synthetic, prompt, tool_filter = resolved + return await run_subagent( parent_ctx=tool_context, archetype=synthetic, @@ -200,6 +244,34 @@ async def _run_async_impl( tool_filter=tool_filter, ) + async def run_streaming( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> AsyncIterator[Any]: + """Progress-streaming entrypoint used when event forwarding is enabled. + + Yields one progress projection per sub-agent event and, as the final + value, the sub-agent's result (which the tools processor turns into the + ``function_response`` fed back to the parent LLM). See + :func:`run_subagent_streaming` for the per-value contract. + """ + resolved = self._resolve_call(args) + if isinstance(resolved, dict): + yield resolved + return + synthetic, prompt, tool_filter = resolved + + async for value in run_subagent_streaming( + parent_ctx=tool_context, + archetype=synthetic, + prompt=prompt, + agent_config=self._agent_config, + tool_filter=tool_filter, + ): + yield value + def _tool_names(tools: tuple) -> list[str]: """Extract declaration names from a tuple of tool items. diff --git a/trpc_agent_sdk/agents/sub_agent/_runner.py b/trpc_agent_sdk/agents/sub_agent/_runner.py index 79302901..d35bd215 100644 --- a/trpc_agent_sdk/agents/sub_agent/_runner.py +++ b/trpc_agent_sdk/agents/sub_agent/_runner.py @@ -19,7 +19,9 @@ from __future__ import annotations from typing import Any +from typing import AsyncIterator from typing import Optional +from typing import TypedDict from typing import Union from trpc_agent_sdk.abc import ArtifactId @@ -246,21 +248,99 @@ def _extract_final_text(last_event) -> str: return "\n".join(p.text for p in last_event.content.parts if getattr(p, "text", None)) -async def run_subagent( +class SubAgentProgress(TypedDict, total=False): + """Wire contract for a forwarded sub-agent progress event. + + This is the ``payload`` dict a consumer receives for each ``partial=True`` + progress event when ``forward_events`` is enabled. + + ``content`` is the sub-agent event's :class:`Content` dumped with + ``model_dump(exclude_none=True)`` — the same shape used everywhere else in + the framework (``parts[i].function_call.name``, ``parts[i].text`` / + ``thought``), so consumers reuse the structure they already know instead of + a bespoke schema. ``error`` and ``usage`` are lifted from the ``Event`` + itself (they do not live on ``content``). ``total=False`` because + ``content`` / ``error`` / ``usage`` are only present when the underlying + event carries them. + + Only ``content`` — never the whole ``Event`` — crosses the boundary, so + ``actions`` / ``state_delta`` (parent-context state) are not leaked. + """ + + author: Optional[str] + partial: bool + content: dict + error: dict + usage: dict + + +def _project_subagent_event(event: Any) -> SubAgentProgress: + """Project a sub-agent event into a lightweight, JSON-serializable dict. + + Forwarded to the parent runner's consumer as the ``payload`` of a progress + event when ``forward_events`` is enabled. See :class:`SubAgentProgress` for + the shape. Uses the framework-native ``Content`` dump for the event body + rather than a custom projection, and deliberately dumps only ``content`` + (not the whole ``Event``) so parent-context state never crosses the + isolation boundary. + """ + payload: SubAgentProgress = { + "author": getattr(event, "author", None), + "partial": bool(getattr(event, "partial", False)), + } + + # Framework-native content shape (parts / function_call / text / thought). + # Dump only content — actions / state_delta live on the Event and must not + # cross the isolation boundary. + content = getattr(event, "content", None) + dump = getattr(content, "model_dump", None) if content is not None else None + if callable(dump): + payload["content"] = dump(exclude_none=True) + + # Surface sub-agent run errors so the consumer can render them instead of + # showing an empty event. Added conditionally to keep the payload clean on + # the common (no-error) path. See Event.is_error(): error_code drives it. + error_code = getattr(event, "error_code", None) + error_message = getattr(event, "error_message", None) + if error_code is not None or error_message is not None: + payload["error"] = {"code": error_code, "message": error_message} + + # Token usage for cost observability. Only the headline counts are lifted + # out of the usage_metadata object (which is not JSON-serializable as-is). + usage = getattr(event, "usage_metadata", None) + if usage is not None: + payload["usage"] = { + "prompt_tokens": getattr(usage, "prompt_token_count", None), + "completion_tokens": getattr(usage, "candidates_token_count", None), + "total_tokens": getattr(usage, "total_token_count", None), + } + + return payload + + +async def run_subagent_streaming( *, parent_ctx: InvocationContext, archetype: SubAgentArchetype, prompt: str, agent_config=None, tool_filter: Optional[list] = None, -) -> Union[str, dict]: - """Run an isolated sub-agent and return its final assistant text. - - Returns: - Final assistant text on success, ``"[sub-agent cancelled]"`` if the - run was cancelled, or ``{"status": "error", "message": ...}`` on - unexpected exceptions. Errors are not raised back to the parent so - the orchestrator can decide how to react. +) -> AsyncIterator[Union[str, dict]]: + """Run an isolated sub-agent, yielding progress projections then the result. + + Yields one projection ``dict`` (see :func:`_project_subagent_event`) per + sub-agent event, and finally the sub-agent's final result as the **last** + yielded value. The final value is the assistant text on success, + ``"[sub-agent cancelled]"`` on cancellation, or + ``{"status": "error", "message": ...}`` on unexpected exceptions — errors are + surfaced as the final value rather than raised, matching + :func:`run_subagent`'s graceful-degradation contract. + + Contract with the progress-streaming tool path: every yielded value except + the last becomes a ``partial=True`` progress event surfaced to the parent + runner's consumer; the last value becomes the tool's ``function_response`` + fed back to the parent LLM. The projection dicts are never persisted into + the parent session nor seen by the parent LLM. """ # Imported lazily to mirror AgentTool and avoid a circular import at module load. from trpc_agent_sdk.runners import Runner @@ -269,7 +349,8 @@ async def run_subagent( sub_agent = _build_sub_agent(archetype, parent_ctx, agent_config=agent_config, tool_filter=tool_filter) except Exception as ex: # noqa: BLE001 logger.error("sub-agent build failed: %s", ex, exc_info=True) - return {"status": "error", "message": str(ex)} + yield {"status": "error", "message": str(ex)} + return parent_app_name = getattr(parent_ctx.session, "app_name", "trpc_app") sub_app_name = f"{parent_app_name}{SUBAGENT_APP_NAME_SUFFIX}{archetype.name}" @@ -285,6 +366,7 @@ async def run_subagent( last_event = None max_turns_reached = False + final_value: Union[str, dict, None] = None try: sub_session = await sub_runner.session_service.create_session( app_name=sub_app_name, @@ -308,6 +390,10 @@ async def run_subagent( new_message=content, ): last_event = event + # Forward this sub-agent event to the parent consumer as a progress + # projection. This is the only divergence from run_subagent's silent + # drain; the projection never reaches the parent LLM. + yield _project_subagent_event(event) # Count LLM calls (one non-partial event per request, including # those with tool calls). Aligns with claw-code-agent. if event.content and not event.partial and not event.is_error(): @@ -322,21 +408,55 @@ async def run_subagent( await _forward_artifacts(sub_runner, sub_session, parent_ctx) except RunCancelledException: - return "[sub-agent cancelled]" + final_value = "[sub-agent cancelled]" except Exception as ex: # noqa: BLE001 logger.error("sub-agent run failed: %s", ex, exc_info=True) - return {"status": "error", "message": str(ex)} + final_value = {"status": "error", "message": str(ex)} finally: try: await sub_runner.close() except Exception as close_ex: # noqa: BLE001 logger.warning("sub-agent runner close failed: %s", close_ex) - result = _extract_final_text(last_event) - if max_turns_reached: - note = "[sub-agent stopped: max turns reached]" - return f"{result}\n\n{note}" if result else note - return result + if final_value is None: + result = _extract_final_text(last_event) + if max_turns_reached: + note = "[sub-agent stopped: max turns reached]" + final_value = f"{result}\n\n{note}" if result else note + else: + final_value = result + yield final_value + + +async def run_subagent( + *, + parent_ctx: InvocationContext, + archetype: SubAgentArchetype, + prompt: str, + agent_config=None, + tool_filter: Optional[list] = None, +) -> Union[str, dict]: + """Run an isolated sub-agent and return its final assistant text. + Non-streaming wrapper around :func:`run_subagent_streaming`: it drains the + progress projections and returns only the final value. -__all__ = ["run_subagent"] + Returns: + Final assistant text on success, ``"[sub-agent cancelled]"`` if the + run was cancelled, or ``{"status": "error", "message": ...}`` on + unexpected exceptions. Errors are not raised back to the parent so + the orchestrator can decide how to react. + """ + final: Union[str, dict] = "" + async for value in run_subagent_streaming( + parent_ctx=parent_ctx, + archetype=archetype, + prompt=prompt, + agent_config=agent_config, + tool_filter=tool_filter, + ): + final = value + return final + + +__all__ = ["run_subagent", "run_subagent_streaming", "SubAgentProgress"] diff --git a/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py b/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py index b49f2e3c..ee54088b 100644 --- a/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py +++ b/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py @@ -10,8 +10,10 @@ import os from typing import Any +from typing import AsyncIterator from typing import List from typing import Optional +from typing import Tuple from typing import Union from typing_extensions import override @@ -29,6 +31,7 @@ from ._loader import load_archetypes_from_dir from ._registry import SubAgentRegistry from ._runner import run_subagent +from ._runner import run_subagent_streaming from ._sub_agent_config import SubAgentConfig @@ -106,6 +109,11 @@ def __init__( self._registry = registry self._skip_summarization = skip_summarization self._agent_config = agent_config + # When the config asks to forward the sub-agent's events, this tool + # behaves as a progress-streaming tool. Resolved once at construction + # because is_progress_streaming is read before execution to route the + # call onto the streaming path. + self._forward_events = bool(agent_config is not None and agent_config.forward_events) rendered = render_tool_description(registry) super().__init__(name="spawn_subagent", description=rendered, filters_name=filters_name, filters=filters) @@ -113,6 +121,27 @@ def __init__( def registry(self) -> SubAgentRegistry: return self._registry + @property + @override + def is_progress_streaming(self) -> bool: + """Route through the progress-streaming path when event forwarding is on. + + Statically determined by ``SubAgentConfig.forward_events`` so + the tools processor can partition this call onto the streaming path + (which drives :meth:`run_streaming`) before execution begins. + """ + return self._forward_events + + @property + def skip_summarization(self) -> bool: + """Whether the streamed sub-agent result is the parent's final answer. + + Read by the progress-streaming execution path (which bypasses + ``_run_async_impl``) to set ``skip_summarization`` on the final + ``function_response`` event. + """ + return self._skip_summarization + @override def _get_declaration(self) -> FunctionDeclaration: return FunctionDeclaration( @@ -169,16 +198,13 @@ async def process_request( "Put everything it needs in `prompt`.") llm_request.append_instructions([instruction]) - @override - async def _run_async_impl( - self, - *, - tool_context: InvocationContext, - args: dict[str, Any], - ) -> Any: - if self._skip_summarization: - tool_context.event_actions.skip_summarization = True + def _resolve_call(self, args: dict[str, Any]) -> Union[Tuple[SubAgentArchetype, str], dict]: + """Parse and validate call args into ``(archetype, prompt)``. + Returns an error dict on an unknown ``subagent_type`` (with no + ``default`` fallback) or a missing/empty ``prompt`` so both the + streaming and non-streaming paths surface the same failure shape. + """ subagent_type = args.get("subagent_type") prompt = args.get("prompt") @@ -196,7 +222,23 @@ async def _run_async_impl( if not isinstance(prompt, str) or not prompt.strip(): return {"status": "error", "message": "prompt must be a non-empty string"} - archetype = self._registry.get(resolved_type) + return self._registry.get(resolved_type), prompt + + @override + async def _run_async_impl( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> Any: + if self._skip_summarization: + tool_context.event_actions.skip_summarization = True + + resolved = self._resolve_call(args) + if isinstance(resolved, dict): + return resolved + archetype, prompt = resolved + return await run_subagent( parent_ctx=tool_context, archetype=archetype, @@ -204,5 +246,32 @@ async def _run_async_impl( agent_config=self._agent_config, ) + async def run_streaming( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> AsyncIterator[Any]: + """Progress-streaming entrypoint used when event forwarding is enabled. + + Yields one progress projection per sub-agent event and, as the final + value, the sub-agent's result (which the tools processor turns into the + ``function_response`` fed back to the parent LLM). See + :func:`run_subagent_streaming` for the per-value contract. + """ + resolved = self._resolve_call(args) + if isinstance(resolved, dict): + yield resolved + return + archetype, prompt = resolved + + async for value in run_subagent_streaming( + parent_ctx=tool_context, + archetype=archetype, + prompt=prompt, + agent_config=self._agent_config, + ): + yield value + __all__ = ["SpawnSubAgentTool"] diff --git a/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py b/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py index 075c9871..1d7af099 100644 --- a/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py +++ b/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py @@ -42,5 +42,11 @@ class SubAgentConfig: """Max LLM calls the sub-agent may make. ``None`` = unlimited. Each LLM request counts as one turn, including those with tool calls.""" + forward_events: bool = False + """Stream sub-agent events to the parent consumer as progress updates. + + ``True``: orchestrator can display sub-agent execution live. + ``False`` (default): sub-agent runs silently.""" + __all__ = ["SubAgentConfig"] From 14d78cd108f70a6bccf77508d45c7d5a25c3f04a Mon Sep 17 00:00:00 2001 From: weimch Date: Fri, 10 Jul 2026 15:18:07 +0800 Subject: [PATCH 13/26] =?UTF-8?q?Bugfix:=20=E4=BF=AE=E5=A4=8D=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=B0=83=E7=94=A8=E5=A4=B1=E8=B4=A5=E6=9C=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=BC=82=E5=B8=B8=E4=BF=A1=E6=81=AF=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在模型支持重试的特性支持里,把这个信息打印给移除了,现在修复这个问题 --- trpc_agent_sdk/models/_retry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/trpc_agent_sdk/models/_retry.py b/trpc_agent_sdk/models/_retry.py index 97d45aba..a62cdca1 100644 --- a/trpc_agent_sdk/models/_retry.py +++ b/trpc_agent_sdk/models/_retry.py @@ -143,6 +143,7 @@ def _compute_exponential_backoff( def _build_error_response(ex: Exception, error_code: str) -> LlmResponse: + logger.error("Model call failed: %s", ex, exc_info=True) return LlmResponse( content=None, error_code=error_code, From 059a3555a3b0dbd0aacd1ee9f66ba25cec0ab933 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Fri, 10 Jul 2026 14:42:22 +0800 Subject: [PATCH 14/26] =?UTF-8?q?version:=20=E5=8F=91=E5=B8=831.1.12?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ tests/test_version.py | 2 +- trpc_agent_sdk/version.py | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b1bb00c..03f5de58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.1.12](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.12) (2026-07-10) + +### Features + +* Agent: Added support for dynamically created sub-agents to forward runtime events directly into the parent agent event stream, so callers can observe child-agent progress, tool activity, and final outputs without waiting for the whole delegated task to finish. +* Agent: Added dynamic sub-agent creation support, allowing agents to create and use child agents at runtime for more flexible task decomposition and delegation. +* Goal: Added Goal support aligned with the Go implementation, giving agents a structured way to carry task objectives through the execution flow. +* A2A: Added optional `app_name` support to `TrpcA2aAgentService`, allowing the Runner app identity to differ from the exposed A2A service name while keeping the existing `service_name` fallback behavior. +* Session: Updated `list_sessions()` so `user_id` can be omitted. When `user_id=None`, InMemory, SQL, Redis, and Eval session services now return all sessions under the specified `app_name` without loading session events. +* Skill: Added the `skills_hub` module to support centralized skill discovery and management. + +### Bug Fixes + +* Graph: Fixed `GraphAgent` `AgentNode.last_response` so it no longer records thinking text or intermediate tool-call round text as the node's final response. The graph now uses `Event.is_final_response()` and removes thinking content before saving the last response. +* A2A: Fixed internal pipeline example scripts and paths so the example workflow can be triggered and run with the expected files. +* Docs: Fixed README optional dependency installation commands by quoting extras, removing extra spaces, and normalizing package-extra casing so shell parsing works correctly. + +### Docs + +* Docs: Added MkDocs site entry pages and navigation for the existing English and Chinese documentation, plus a GitHub Pages workflow so the README documentation badge can point to a published documentation site. +* Docs: Added documentation and test coverage for listing sessions across all users under an app by passing `user_id=None`. + +### Internal + +* CI: Added and adjusted internal pipeline test trigger files used by repository automation. + ## [1.1.11](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.11) (2026-06-26) ### Features diff --git a/tests/test_version.py b/tests/test_version.py index 85896686..a5aaea74 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -10,4 +10,4 @@ def test_version(): """Test the version module.""" - assert __version__ == '1.1.11' + assert __version__ == '1.1.12' diff --git a/trpc_agent_sdk/version.py b/trpc_agent_sdk/version.py index 614d9682..a6e4cbae 100644 --- a/trpc_agent_sdk/version.py +++ b/trpc_agent_sdk/version.py @@ -9,4 +9,4 @@ This module defines the version information for TRPC Agent """ -__version__ = '1.1.11' +__version__ = '1.1.12' From 17316d0f36622f39ac675c865da2687738dd9299 Mon Sep 17 00:00:00 2001 From: congkechen Date: Fri, 10 Jul 2026 14:50:32 +0800 Subject: [PATCH 15/26] Fix examples and Build Full Pipeline --- examples/langgraph_agent/agent/agent.py | 9 +- .../agent/agent.py | 3 +- .../agent/agent.py | 3 +- examples/mem0_tools/agent/agent.py | 4 +- pipeline_test/run_all_examples.sh | 390 ++++++++++++++++++ 5 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 pipeline_test/run_all_examples.sh diff --git a/examples/langgraph_agent/agent/agent.py b/examples/langgraph_agent/agent/agent.py index dfe7e128..31abe342 100644 --- a/examples/langgraph_agent/agent/agent.py +++ b/examples/langgraph_agent/agent/agent.py @@ -39,8 +39,9 @@ def build_calculator_subgraph(): api_key, url, model_name = get_model_config() model = init_chat_model( model_name, + model_provider="openai", api_key=api_key, - api_base=url, + base_url=url, ) tools = [calculate] @@ -79,8 +80,9 @@ def build_graph_with_subgraph(): api_key, url, model_name = get_model_config() model = init_chat_model( model_name, + model_provider="openai", api_key=api_key, - api_base=url, + base_url=url, ) # Build calculator subgraph @@ -126,8 +128,9 @@ def build_graph(): api_key, url, model_name = get_model_config() model = init_chat_model( model_name, + model_provider="openai", api_key=api_key, - api_base=url, + base_url=url, ) tools = [calculate] diff --git a/examples/langgraph_agent_with_cancel/agent/agent.py b/examples/langgraph_agent_with_cancel/agent/agent.py index 5ec88381..99a57136 100644 --- a/examples/langgraph_agent_with_cancel/agent/agent.py +++ b/examples/langgraph_agent_with_cancel/agent/agent.py @@ -44,8 +44,9 @@ def build_graph(): api_key, url, model_name = get_model_config() model = init_chat_model( model_name, + model_provider="openai", api_key=api_key, - api_base=url, + base_url=url, ) tools = [calculate, analyze_data] diff --git a/examples/langgraphagent_with_human_in_the_loop/agent/agent.py b/examples/langgraphagent_with_human_in_the_loop/agent/agent.py index baf750aa..5a3f7fe1 100644 --- a/examples/langgraphagent_with_human_in_the_loop/agent/agent.py +++ b/examples/langgraphagent_with_human_in_the_loop/agent/agent.py @@ -53,8 +53,9 @@ def _build_graph(): api_key, url, model_name = get_model_config() model = init_chat_model( model_name, + model_provider="openai", api_key=api_key, - api_base=url, + base_url=url, ) tools = [execute_database_operation] llm_with_tools = model.bind_tools(tools) diff --git a/examples/mem0_tools/agent/agent.py b/examples/mem0_tools/agent/agent.py index af69d341..3457f447 100644 --- a/examples/mem0_tools/agent/agent.py +++ b/examples/mem0_tools/agent/agent.py @@ -10,8 +10,8 @@ from trpc_agent_sdk.agents import LlmAgent from trpc_agent_sdk.models import LLMModel from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools.mem0_tools import SaveMemoryTool -from trpc_agent_sdk.tools.mem0_tools import SearchMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SaveMemoryTool +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool from .config import get_mem0_platform_config from .config import get_memory_config diff --git a/pipeline_test/run_all_examples.sh b/pipeline_test/run_all_examples.sh new file mode 100644 index 00000000..89c1fb46 --- /dev/null +++ b/pipeline_test/run_all_examples.sh @@ -0,0 +1,390 @@ +#!/bin/bash + +export DISABLE_TRPC_AGENT_REPORT=true + +set -u +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +RUN_MODE="all" +FAIL_FAST=false +INCLUDE_MANUAL=false +EXAMPLE_TIMEOUT_SECONDS="${EXAMPLE_TIMEOUT_SECONDS:-300}" + +PASSED=() +FAILED=() +SKIPPED=() +RUN_AGENT_EXAMPLES=() +EVALUATION_TESTS=() + +TOTAL_TASKS=0 +CURRENT_TASK=0 + +SKIPPED_EXAMPLES=( + "examples/claude_agent_with_travel_planner/run_agent.py" + "examples/dsl/classifier_mcp/run_agent.py" + "examples/evaluation/webui/test_book_finder.py" + "examples/knowledge_with_vectorstore/run_agent.py" + "examples/mem0_tools/run_agent.py" + "examples/memory_service_with_mem0/run_agent.py" + "examples/memory_service_with_mempalace/run_agent.py" + "examples/memory_service_with_redis/run_agent.py" + "examples/memory_service_with_sql/run_agent.py" + "examples/mempalace_tools/run_agent.py" + "examples/session_service_with_redis/run_agent.py" + "examples/session_service_with_sql/run_agent.py" + "examples/skills_hub/run_agent.py" + "examples/skills_with_container/run_agent.py" + "examples/skills_with_cube/run_agent.py" +) + +show_usage() { + cat < 0)); then + percent=$((current * 100 / total)) + fi + + echo + printf '[%d/%d] %3d%% %s\n' "$current" "$total" "$percent" "$name" +} + +skip_example() { + local name="$1" + local reason="$2" + + CURRENT_TASK=$((CURRENT_TASK + 1)) + show_progress "$CURRENT_TASK" "$TOTAL_TASKS" "Skipping: ${name}" + echo "Skipping ${name}: ${reason}" + record_result skip "$name" +} + +should_skip() { + local name="$1" + shift + + if [[ "$INCLUDE_MANUAL" != true ]] && is_in_list "$name" "${SKIPPED_EXAMPLES[@]}"; then + SKIP_REASON="skipped by default" + return 0 + fi + + if (($# > 0)) && is_in_list "$name" "$@"; then + SKIP_REASON="listed in EXTRA_SKIP_EXAMPLES" + return 0 + fi + + return 1 +} + +run_command() { + local name="$1" + shift + local exit_code + + CURRENT_TASK=$((CURRENT_TASK + 1)) + show_progress "$CURRENT_TASK" "$TOTAL_TASKS" "Running: ${name}" + echo "============================================================" + echo "Running: ${name}" + echo "Command: $*" + echo "============================================================" + + "$@" + exit_code=$? + + if [[ "$exit_code" -eq 0 ]]; then + record_result pass "$name" + return 0 + else + echo "FAILED: ${name} (exit code: ${exit_code})" + record_result fail "$name" + + if [[ "$exit_code" -eq 130 ]]; then + print_summary + exit "$exit_code" + fi + + if [[ "$FAIL_FAST" == true ]]; then + print_summary + exit "$exit_code" + fi + + return "$exit_code" + fi +} + +run_python_file_from_dir() { + local file_path="$1" + local work_dir + local file_name + + work_dir="$(dirname "$file_path")" + file_name="$(basename "$file_path")" + + ( + cd "${REPO_ROOT}/${work_dir}" + timeout "$EXAMPLE_TIMEOUT_SECONDS" python3 "$file_name" + ) +} + +run_pytest_file_from_dir() { + local file_path="$1" + local work_dir + local file_name + + work_dir="$(dirname "$file_path")" + file_name="$(basename "$file_path")" + + ( + cd "${REPO_ROOT}/${work_dir}" + timeout "$EXAMPLE_TIMEOUT_SECONDS" pytest "$file_name" -v -s + ) +} + +run_discovered_agents() { + local extra_skip=() + local example + if [[ -n "${EXTRA_SKIP_EXAMPLES:-}" ]]; then + read -r -a extra_skip <<< "${EXTRA_SKIP_EXAMPLES}" + fi + + for example in "${RUN_AGENT_EXAMPLES[@]}"; do + if should_skip "$example" "${extra_skip[@]}"; then + skip_example "$example" "$SKIP_REASON" + continue + fi + + run_command "$example" run_python_file_from_dir "$example" + done +} + +run_evaluation_tests() { + local test_file + for test_file in "${EVALUATION_TESTS[@]}"; do + if should_skip "$test_file"; then + skip_example "$test_file" "$SKIP_REASON" + continue + fi + + run_command "$test_file" run_pytest_file_from_dir "$test_file" + done +} + +wait_for_port() { + local host="$1" + local port="$2" + local timeout_seconds="$3" + + python3 - "$host" "$port" "$timeout_seconds" <<'PY' +import socket +import sys +import time + +host = sys.argv[1] +port = int(sys.argv[2]) +deadline = time.time() + int(sys.argv[3]) + +while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + if sock.connect_ex((host, port)) == 0: + sys.exit(0) + time.sleep(1) + +sys.exit(1) +PY +} + +run_a2a_example() { + local server_pid="" + local test_status=0 + + ( + cd "${REPO_ROOT}/examples/a2a" + exec python3 run_server.py + ) & + server_pid=$! + + cleanup_a2a() { + if [[ -n "$server_pid" ]]; then + pkill -TERM -P "$server_pid" 2>/dev/null || true + kill "$server_pid" 2>/dev/null || true + sleep 2 + pkill -KILL -P "$server_pid" 2>/dev/null || true + kill -KILL "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + } + + if ! wait_for_port "127.0.0.1" "18081" "30"; then + echo "FAILED: examples/a2a server did not start on 127.0.0.1:18081" + cleanup_a2a + return 1 + fi + + ( + cd "${REPO_ROOT}/examples/a2a" + timeout "$EXAMPLE_TIMEOUT_SECONDS" python3 test_a2a.py + ) + test_status=$? + cleanup_a2a + return "$test_status" +} + +discover_run_agent_examples() { + mapfile -t RUN_AGENT_EXAMPLES < <( + cd "$REPO_ROOT" + find examples -path "*/run_agent.py" -type f | sort + ) +} + +discover_evaluation_tests() { + mapfile -t EVALUATION_TESTS < <( + cd "$REPO_ROOT" + find examples/evaluation -name "test_*.py" -type f | sort + ) +} + +prepare_tasks() { + case "$RUN_MODE" in + all) + discover_run_agent_examples + discover_evaluation_tests + TOTAL_TASKS=$((${#RUN_AGENT_EXAMPLES[@]} + ${#EVALUATION_TESTS[@]} + 1)) + ;; + run-agent) + discover_run_agent_examples + TOTAL_TASKS=${#RUN_AGENT_EXAMPLES[@]} + ;; + evaluation) + discover_evaluation_tests + TOTAL_TASKS=${#EVALUATION_TESTS[@]} + ;; + a2a) + TOTAL_TASKS=1 + ;; + esac +} + +print_summary() { + echo + echo "==================== Example Run Summary ====================" + echo "Passed : ${#PASSED[@]}" + echo "Failed : ${#FAILED[@]}" + echo "Skipped: ${#SKIPPED[@]}" + + if ((${#FAILED[@]} > 0)); then + echo + echo "Failed examples:" + printf ' - %s\n' "${FAILED[@]}" + fi +} + +while (($# > 0)); do + case "$1" in + all|run-agent|evaluation|a2a) + RUN_MODE="$1" + ;; + --fail-fast) + FAIL_FAST=true + ;; + --include-manual) + INCLUDE_MANUAL=true + ;; + -h|--help) + show_usage + exit 0 + ;; + *) + echo "Error: unknown argument '$1'" + show_usage + exit 1 + ;; + esac + shift +done + +cd "$REPO_ROOT" +prepare_tasks + +case "$RUN_MODE" in + all) + run_discovered_agents + run_evaluation_tests + run_command "examples/a2a" run_a2a_example + ;; + run-agent) + run_discovered_agents + ;; + evaluation) + run_evaluation_tests + ;; + a2a) + run_command "examples/a2a" run_a2a_example + ;; +esac + +print_summary + +if ((${#FAILED[@]} > 0)); then + exit 1 +fi From e113610d558f7f54c8e150625ae3c949656a21c0 Mon Sep 17 00:00:00 2001 From: congkechen Date: Fri, 10 Jul 2026 16:18:56 +0800 Subject: [PATCH 16/26] add release workflow --- .github/workflows/release.yml | 89 +++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d3626383 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +name: Auto Release when Tag +on: + push: + tags: + - 'v*' + - 'test/v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + runs-on: [self-hosted, trpc-agent-python-ci] + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install release dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + pip install build twine flake8 yapf + + - name: Validate tag version + run: | + TAG_VERSION="${GITHUB_REF_NAME#test/v}" + TAG_VERSION="${TAG_VERSION#v}" + PACKAGE_VERSION=$(python -c "from trpc_agent_sdk.version import __version__; print(__version__)") + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "::error::Tag version '$TAG_VERSION' does not match package version '$PACKAGE_VERSION'." + exit 1 + fi + + - name: Get changed Python files + id: changed + run: | + FILES=$(git diff --name-only --diff-filter=ACM HEAD~1...HEAD -- '*.py' | grep '^trpc_agent_sdk/' || true) + if [ -z "$FILES" ]; then + echo "has_files=false" >> "$GITHUB_OUTPUT" + else + echo "has_files=true" >> "$GITHUB_OUTPUT" + echo "$FILES" > "$RUNNER_TEMP/changed_py_files.txt" + echo "Changed Python files:" + echo "$FILES" + fi + + - name: Check formatting with YAPF + if: steps.changed.outputs.has_files == 'true' + run: | + FILES=$(cat "$RUNNER_TEMP/changed_py_files.txt" | tr '\n' ' ') + diff_output=$(yapf --diff $FILES) || true + if [ -n "$diff_output" ]; then + echo "$diff_output" + echo "::error::Code formatting check failed for changed files. Run 'yapf -i ' to fix." + exit 1 + fi + + - name: Lint with flake8 + if: steps.changed.outputs.has_files == 'true' + run: | + FILES=$(cat "$RUNNER_TEMP/changed_py_files.txt" | tr '\n' ' ') + flake8 $FILES + + - name: Run tests with coverage + run: | + pytest --cov=trpc_agent_sdk --cov-report=xml --cov-report=term --cov-fail-under=80 tests/ + + - name: Build package + run: | + python -m build + + - name: Check package + run: | + python -m twine check dist/* + + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + run: | + python -m twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file From f8b05ba105fd9edb28fcfbef7834e29ea541f75e Mon Sep 17 00:00:00 2001 From: CongkeChen Date: Thu, 16 Jul 2026 11:14:11 +0800 Subject: [PATCH 17/26] Code Review (#191) * AI review prompt * Extend example_timeout_second in all_example_test --- .github/ai-review-prompt.md | 78 +++++++++++++++++++++++++++++++ pipeline_test/run_all_examples.sh | 2 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .github/ai-review-prompt.md diff --git a/.github/ai-review-prompt.md b/.github/ai-review-prompt.md new file mode 100644 index 00000000..d05928fd --- /dev/null +++ b/.github/ai-review-prompt.md @@ -0,0 +1,78 @@ +你是一个资深代码审查助手。请审查当前 PR 的代码变更,并给出稳定、可复用、可执行的 review 结论。 + +审查范围: +1. 以仓库根目录下的 `pr.diff` 作为审查主范围,只反馈落在 diff 变更中的问题。 +2. 允许结合仓库中的相关上下文辅助判断 diff 是否存在问题,例如被调用方、配置、类型定义、测试、公共函数和上下游调用关系;但不要脱离 diff 单独审查未修改代码。 +3. 不要修改任何文件,不要提交代码。 +4. 只反馈可以从 diff 及其相关仓库上下文中定位和验证的问题;不要基于猜测扩展到未修改代码。 +5. 每个问题必须标注文件路径和行号。行号优先使用 diff 中新增代码对应的目标文件行号;如果只能定位到代码块,请说明“附近行”。 + +审查重点: +1. 正确性:逻辑错误、状态流转错误、条件判断错误、返回值或异常处理错误。 +2. 安全性:凭证泄露、命令注入、路径穿越、不可信输入未校验、权限绕过、敏感信息输出。 +3. 稳定性:边界条件缺失、空值处理、并发或异步时序问题、资源未释放、超时和重试缺失。 +4. 兼容性:公开 API、配置项、持久化数据、CI/workflow 行为的破坏性变更。 +5. 测试有效性:高风险逻辑缺少必要测试,或测试没有覆盖实际风险路径。 +6. 可维护性:仅在影响理解、扩展或长期维护时提出,不输出泛泛的代码风格建议。 + +质量等级定义: +- 🚨 Critical:必须修复的问题。包括安全漏洞、明确的逻辑错误、会导致核心功能失败、数据错误、权限问题、CI 阻塞或线上风险的问题。 +- ⚠️ Warning:建议修复的问题。包括性能隐患、边界条件缺失、异常路径不完整、兼容性风险、测试覆盖不足等可能导致问题但不一定立即失败的情况。 +- 💡 Suggestion:可选优化。包括代码可读性、结构简化、维护性提升、轻微重复等不影响当前正确性的改进建议。 + +输出要求: +1. 使用 Markdown 输出,保持简洁,避免长篇解释。 +2. 先列问题,再给总结。 +3. 按 Critical、Warning、Suggestion 的顺序输出;同级别内按风险从高到低排序。 +4. 合并同类问题:同一根因、同一文件相邻代码、同一修复方式的问题应合并为一条,不要重复展开,也不要用不同表述重复描述同一问题。 +5. 优先输出高价值问题。Critical 和 Warning 不限数量但要合并;Suggestion 最多 2 条,且只有在确实有维护价值时输出。 +6. 每条问题控制在 2-3 句话内:说明问题、影响和修复方向即可,不要分别展开大段“问题/影响/建议”。 +7. 问题位置必须使用反引号包裹的 `文件路径:行号` 或 `文件路径:起始行-结束行` 格式,便于评论自动生成 GitHub 跳转链接。 +8. 如果没有发现明确问题,请直接说明“未发现明显阻塞问题”,不要为了完整性补充低价值建议。 +9. 如果同一段代码同时涉及多个相关风险(如命令注入、路径穿越),优先合并成一条,除非修复方式或影响范围明显不同。 +10. 对测试代码、示例代码、演示代码也按真实执行风险评估;如果这些代码会进入 CI、测试运行路径或被其他开发者复用,不要因为“仅用于示例/测试”而降低问题等级。 +11. 仅当问题离开具体代码片段不易理解时,才附带 3-8 行最小必要代码片段;前后可用 `...` 省略,不要默认给每个问题都附代码块,也不要贴大段代码。 +12. 结论要尽量确定:只有在确实无法从 diff 验证时,才使用“可能”“疑似”等措辞。 + +输出格式: + +## 发现的问题 + +如果存在问题,请按下面格式逐条输出。每条尽量一行位置 + 一段简短说明: + +### 🚨 Critical + +- `文件路径:行号`:问题标题 + - 简要说明具体风险和建议修复方式。 + - 如有必要,可附最小必要代码片段: + ```python + ... + 问题代码 + ... + ``` + +### ⚠️ Warning + +- `文件路径:行号`:问题标题 + - 简要说明具体风险和建议修复方式。 + - 如有必要,可附最小必要代码片段: + ```python + ... + 问题代码 + ... + ``` + +### 💡 Suggestion + +- `文件路径:行号`:问题标题 + - 简要说明维护性影响和可选优化方式。 + +如果某个等级没有问题,可以省略该等级。 + +## 总结 + +用 1-2 句话说明整体风险,以及是否存在必须修复的问题。 + +## 测试建议 + +如果需要补充测试,用 1-2 条说明建议覆盖的场景;如果不需要,请说明“暂无额外测试建议”。 \ No newline at end of file diff --git a/pipeline_test/run_all_examples.sh b/pipeline_test/run_all_examples.sh index 89c1fb46..7d7bc45c 100644 --- a/pipeline_test/run_all_examples.sh +++ b/pipeline_test/run_all_examples.sh @@ -11,7 +11,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" RUN_MODE="all" FAIL_FAST=false INCLUDE_MANUAL=false -EXAMPLE_TIMEOUT_SECONDS="${EXAMPLE_TIMEOUT_SECONDS:-300}" +EXAMPLE_TIMEOUT_SECONDS="${EXAMPLE_TIMEOUT_SECONDS:-500}" PASSED=() FAILED=() From 1903a9fda35b575d8103703ac06da24c0a35df0e Mon Sep 17 00:00:00 2001 From: congkechen Date: Thu, 16 Jul 2026 17:51:07 +0800 Subject: [PATCH 18/26] Code Review --- .github/code_review/prompts/findings.md | 64 +++++++ .github/code_review/prompts/review.md | 78 ++++++++ .github/code_review/scripts/evaluate_gate.py | 24 +++ .../scripts/post_inline_comments.py | 170 ++++++++++++++++++ .../scripts/post_review_comment.py | 75 ++++++++ 5 files changed, 411 insertions(+) create mode 100644 .github/code_review/prompts/findings.md create mode 100644 .github/code_review/prompts/review.md create mode 100644 .github/code_review/scripts/evaluate_gate.py create mode 100644 .github/code_review/scripts/post_inline_comments.py create mode 100644 .github/code_review/scripts/post_review_comment.py diff --git a/.github/code_review/prompts/findings.md b/.github/code_review/prompts/findings.md new file mode 100644 index 00000000..15aa0c90 --- /dev/null +++ b/.github/code_review/prompts/findings.md @@ -0,0 +1,64 @@ +你现在的任务不是重新做代码审查,而是将已经生成的 `review.md` 提取为结构化结果,供后续脚本用于行内评论、阻断判断和其他自动化处理。 + +输入文件: +1. 仓库根目录下的 `review.md`:这是主依据,包含第一阶段已经确认的问题。 +2. 仓库根目录下的 `pr.diff`:仅用于辅助定位文件路径和行号,必要时可用于补全位置;不要基于 `pr.diff` 新增问题。 + +任务要求: +1. 只提取 `review.md` 中已经明确列出的问题,不要新增、扩展或重写任何新问题。 +2. 不要重新判断代码是否有问题;第一阶段 review 的结论已经成立,你的任务只是标准化提取。 +3. 如果 `review.md` 中某个问题缺少明确的文件路径或行号,不要编造;但不要跳过该问题,仍应保留该 finding,并将 `inline_candidate` 设为 `false`。在这种情况下,`path`、`start_line`、`end_line` 可留空字符串、0 或与定位失败一致的保守值,但不能让该问题从结果中消失。 +4. 允许参考 `pr.diff` 来确认问题对应的目标文件路径、起始行和结束行,但不要因此新增新的发现。 +5. 输出必须是严格合法的 JSON,且只能输出 JSON,不要输出 Markdown、解释文字、代码块、前后缀或额外说明。 +6. 输出必须直接以 `{` 开始、以 `}` 结束;不要使用 ```json、``` 或任何其他 Markdown 代码块围栏包裹 JSON。 + +严重级别映射: +- `Critical` -> `critical` +- `Warning` -> `warning` +- `Suggestion` -> `suggestion` + +输出 JSON 格式: +```json +{ + "findings": [ + { + "severity": "critical", + "path": "relative/path/to/file.py", + "start_line": 12, + "end_line": 12, + "title": "问题标题", + "body": "1-3 句简洁说明,适合直接用于 GitHub 行内评论。", + "inline_candidate": true + } + ] +} +``` + +字段要求: +1. `severity`:必须是 `critical`、`warning`、`suggestion` 之一。 +2. `path`:仓库相对路径,例如 `tests/ai_test.py`;如果确实无法稳定定位,可为空字符串,但该 finding 仍需保留。 +3. `start_line`:问题起始行号,必须优先使用变更后文件的目标行号;如果确实无法稳定定位,可为 `0`,但该 finding 仍需保留。 +4. `end_line`: + - 单行问题时,填写与 `start_line` 相同的值; + - 范围问题时,填写结束行号。 +5. `title`:简短问题标题,适合作为行内评论标题,不要过长。 +6. `body`:1-3 句简洁说明,说明风险和建议修复方向,适合直接显示在代码旁边。 +7. `inline_candidate`: + - `true`:适合做行内评论,问题能明确归因到一个文件的一行或一小段范围,且适合直接挂在代码旁解释。 + - `false`:更适合放在总评论中,例如跨文件、跨逻辑链路、整体性结论、位置不稳定或无法明确锚定到 diff 行的问题。 + +提取规则: +1. 如果 `review.md` 中同一个问题覆盖多个独立位置,只有在这些位置都适合单独挂行内评论时,才拆成多条 findings;否则保持为一条并将 `inline_candidate` 设为 `false`。 +2. 如果一个问题已经在第一阶段被合并表述为一个综合问题(例如“命令注入 + 路径穿越”),不要在这里再次拆分成多个新问题,除非 `review.md` 本身已经明确拆开。 +3. `body` 不要照抄整段 review 原文;应提炼成适合行内评论展示的短说明。 +4. 不允许因为定位失败、信息缺失或不适合行内评论而省略第一阶段已经明确列出的问题;这类问题必须保留在 `findings` 中,并通过 `inline_candidate=false` 与保守字段值表达。 +5. 如果 `review.md` 中没有任何问题,输出: +```json +{"findings":[]} +``` + +校验要求: +1. 输出必须能被标准 JSON 解析器直接解析。 +2. 所有字符串必须使用双引号。 +3. 不要在 JSON 外再输出任何额外字符。 +4. 不要输出 ```json 或 ```,也不要输出任何注释。 diff --git a/.github/code_review/prompts/review.md b/.github/code_review/prompts/review.md new file mode 100644 index 00000000..67eb19f4 --- /dev/null +++ b/.github/code_review/prompts/review.md @@ -0,0 +1,78 @@ +你是一个资深代码审查助手。请审查当前 PR 的代码变更,并给出稳定、可复用、可执行的 review 结论。 + +审查范围: +1. 以仓库根目录下的 `pr.diff` 作为审查主范围,只反馈落在 diff 变更中的问题。 +2. 允许结合仓库中的相关上下文辅助判断 diff 是否存在问题,例如被调用方、配置、类型定义、测试、公共函数和上下游调用关系;但不要脱离 diff 单独审查未修改代码。 +3. 不要修改任何文件,不要提交代码。 +4. 只反馈可以从 diff 及其相关仓库上下文中定位和验证的问题;不要基于猜测扩展到未修改代码。 +5. 每个问题必须标注文件路径和行号。行号优先使用 diff 中新增代码对应的目标文件行号;如果只能定位到代码块,请说明“附近行”。 + +审查重点: +1. 正确性:逻辑错误、状态流转错误、条件判断错误、返回值或异常处理错误。 +2. 安全性:凭证泄露、命令注入、路径穿越、不可信输入未校验、权限绕过、敏感信息输出。 +3. 稳定性:边界条件缺失、空值处理、并发或异步时序问题、资源未释放、超时和重试缺失。 +4. 兼容性:公开 API、配置项、持久化数据、CI/workflow 行为的破坏性变更。 +5. 测试有效性:高风险逻辑缺少必要测试,或测试没有覆盖实际风险路径。 +6. 可维护性:仅在影响理解、扩展或长期维护时提出,不输出泛泛的代码风格建议。 + +质量等级定义: +- 🚨 Critical:必须修复的问题。包括安全漏洞、明确的逻辑错误、会导致核心功能失败、数据错误、权限问题、CI 阻塞或线上风险的问题。 +- ⚠️ Warning:建议修复的问题。包括性能隐患、边界条件缺失、异常路径不完整、兼容性风险、测试覆盖不足等可能导致问题但不一定立即失败的情况。 +- 💡 Suggestion:可选优化。包括代码可读性、结构简化、维护性提升、轻微重复等不影响当前正确性的改进建议。 + +输出要求: +1. 使用 Markdown 输出,保持简洁,避免长篇解释。 +2. 先列问题,再给总结。 +3. 按 Critical、Warning、Suggestion 的顺序输出;同级别内按风险从高到低排序。 +4. 合并同类问题:同一根因、同一文件相邻代码、同一修复方式的问题应合并为一条,不要重复展开,也不要用不同表述重复描述同一问题。 +5. 优先输出高价值问题。Critical 和 Warning 不限数量但要合并;Suggestion 最多 2 条,且只有在确实有维护价值时输出。 +6. 每条问题控制在 2-3 句话内:说明问题、影响和修复方向即可,不要分别展开大段“问题/影响/建议”。 +7. 问题位置必须使用反引号包裹的 `文件路径:行号` 或 `文件路径:起始行-结束行` 格式,便于评论自动生成 GitHub 跳转链接。 +8. 如果没有发现明确问题,请直接说明“未发现明显阻塞问题”,不要为了完整性补充低价值建议。 +9. 如果同一段代码同时涉及多个相关风险(如命令注入、路径穿越),优先合并成一条,除非修复方式或影响范围明显不同。 +10. 对测试代码、示例代码、演示代码也按真实执行风险评估;如果这些代码会进入 CI、测试运行路径或被其他开发者复用,不要因为“仅用于示例/测试”而降低问题等级。 +11. 仅当问题离开具体代码片段不易理解时,才附带 3-8 行最小必要代码片段;前后可用 `...` 省略,不要默认给每个问题都附代码块,也不要贴大段代码。 +12. 结论要尽量确定:只有在确实无法从 diff 验证时,才使用“可能”“疑似”等措辞。 + +输出格式: + +## 发现的问题 + +如果存在问题,请按下面格式逐条输出。每条尽量一行位置 + 一段简短说明: + +### 🚨 Critical + +- `文件路径:行号`:问题标题 + - 简要说明具体风险和建议修复方式。 + - 如有必要,可附最小必要代码片段: + ```python + ... + 问题代码 + ... + ``` + +### ⚠️ Warning + +- `文件路径:行号`:问题标题 + - 简要说明具体风险和建议修复方式。 + - 如有必要,可附最小必要代码片段: + ```python + ... + 问题代码 + ... + ``` + +### 💡 Suggestion + +- `文件路径:行号`:问题标题 + - 简要说明维护性影响和可选优化方式。 + +如果某个等级没有问题,可以省略该等级。 + +## 总结 + +用 1-2 句话说明整体风险,以及是否存在必须修复的问题。 + +## 测试建议 + +如果需要补充测试,用 1-2 条说明建议覆盖的场景;如果不需要,请说明“暂无额外测试建议”。 diff --git a/.github/code_review/scripts/evaluate_gate.py b/.github/code_review/scripts/evaluate_gate.py new file mode 100644 index 00000000..c89ea29e --- /dev/null +++ b/.github/code_review/scripts/evaluate_gate.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import json +import os +import sys + + +def main(): + findings_path = os.environ.get("FINDINGS_PATH", "findings.json") + with open(findings_path, "r", encoding="utf-8") as f: + data = json.load(f) + + findings = data.get("findings", []) + for finding in findings: + if finding.get("severity") == "critical": + print("Critical issues found in AI Code Review, blocking pipeline") + return 1 + + print("No critical findings, pipeline can continue") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/code_review/scripts/post_inline_comments.py b/.github/code_review/scripts/post_inline_comments.py new file mode 100644 index 00000000..5a6d0324 --- /dev/null +++ b/.github/code_review/scripts/post_inline_comments.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import sys +import urllib.error +import urllib.request + + +def read_findings(path): + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("findings", []) + + +def build_position_map(diff_path): + with open(diff_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + position_map = {} + current_path = None + new_line_no = None + position = 0 + in_hunk = False + + for raw in lines: + line = raw.rstrip("\n") + + if line.startswith("diff --git "): + current_path = None + new_line_no = None + position = 0 + in_hunk = False + continue + + if line.startswith("+++ b/"): + current_path = line[6:] + if current_path not in position_map: + position_map[current_path] = {} + continue + + if line.startswith("@@ "): + match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@", line) + if match: + new_line_no = int(match.group(1)) + in_hunk = True + continue + + if not in_hunk or current_path is None: + continue + + if not line: + continue + + prefix = line[0] + if prefix not in (" ", "+", "-"): + continue + + position += 1 + + if prefix == " ": + position_map[current_path][new_line_no] = position + new_line_no += 1 + elif prefix == "+": + position_map[current_path][new_line_no] = position + new_line_no += 1 + elif prefix == "-": + continue + + return position_map + + +def build_payload(commit_id, path, position, title, body): + comment_body = "**%s**" % title + if body: + comment_body += "\n\n%s" % body + return { + "body": comment_body, + "commit_id": commit_id, + "path": path, + "position": position, + } + + +def post_inline_comment(api_url, token, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + api_url, + data=data, + method="POST", + headers={ + "Authorization": "Bearer %s" % token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + return True, "status=%s" % resp.status + except urllib.error.HTTPError as e: + try: + err = e.read().decode("utf-8", errors="ignore") + except Exception: + err = "" + return False, "status=%s body=%s" % (e.code, err) + + +def main(): + findings_path = os.environ.get("FINDINGS_PATH", "findings.json") + diff_path = os.environ.get("PR_DIFF_PATH", "pr.diff") + api_url = os.environ["INLINE_COMMENT_API_URL"] + token = os.environ["GITHUB_TOKEN"] + commit_id = os.environ["GITHUB_HEAD_SHA"] + + findings = read_findings(findings_path) + if not findings: + print("no findings found, skip inline comments") + return 0 + + position_map = build_position_map(diff_path) + created = 0 + + for finding in findings: + if finding.get("severity") != "critical": + continue + if not finding.get("inline_candidate"): + continue + + path = finding.get("path") + start_line = finding.get("start_line") + end_line = finding.get("end_line") + title = finding.get("title", "").strip() + body = finding.get("body", "").strip() + + if not path or not start_line or not title: + print("skip finding without required fields: %s" % json.dumps(finding, ensure_ascii=False)) + continue + + start_line = int(start_line) + end_line = int(end_line) if end_line else None + target_line = start_line + + file_positions = position_map.get(path, {}) + position = file_positions.get(target_line) + if position is None and end_line and end_line > start_line: + position = file_positions.get(end_line) + if position is not None: + target_line = end_line + + target = "%s:%s-%s" % (path, start_line, end_line) if end_line and end_line > start_line else "%s:%s" % (path, start_line) + + if position is None: + print("skip finding without diff position: %s" % target) + continue + + payload = build_payload(commit_id, path, position, title, body) + ok, msg = post_inline_comment(api_url, token, payload) + if ok: + created += 1 + print("inline comment created: %s position=%s %s" % (target, position, msg)) + else: + print("inline comment failed: %s position=%s %s" % (target, position, msg)) + + print("inline comments created: %s" % created) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/code_review/scripts/post_review_comment.py b/.github/code_review/scripts/post_review_comment.py new file mode 100644 index 00000000..89bcfa03 --- /dev/null +++ b/.github/code_review/scripts/post_review_comment.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import sys +import urllib.error +import urllib.request + + +def load_review(path): + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + + +def add_github_links(body, repo_url, head_sha): + pattern = re.compile(r"`((?:\.?[\w.-]+/)*[\w.-]+\.[\w.+-]+):(\d+)(?:-(\d+))?`") + + def repl(match): + file_path = match.group(1) + start = match.group(2) + end = match.group(3) + label = "%s:%s%s" % (file_path, start, ("-%s" % end) if end else "") + anchor = "#L%s%s" % (start, ("-L%s" % end) if end else "") + url = "%s/blob/%s/%s%s" % (repo_url, head_sha, file_path, anchor) + return "[`%s`](%s)" % (label, url) + + return pattern.sub(repl, body) + + +def post_comment(api_url, token, body): + payload = json.dumps({"body": body}).encode("utf-8") + req = urllib.request.Request( + api_url, + data=payload, + method="POST", + headers={ + "Authorization": "Bearer %s" % token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + return resp.status, resp.read().decode("utf-8", errors="ignore") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8", errors="ignore") + + +def main(): + review_path = os.environ.get("REVIEW_PATH", "review.md") + api_url = os.environ["COMMENT_API_URL"] + repo_url = os.environ["GITHUB_WEB_REPO_URL"].rstrip("/") + head_sha = os.environ["GITHUB_HEAD_SHA"] + token = os.environ["GITHUB_TOKEN"] + + body = load_review(review_path) + if not body: + body = "AI Code Review 未生成有效结果,请检查流水线日志。" + + body = add_github_links(body, repo_url, head_sha) + status, response = post_comment(api_url, token, "## AI Code Review\n\n%s" % body) + + print("comment http code: %s" % status) + if status < 200 or status >= 300: + print("failed to create github comment") + print(response) + return 1 + + print("comment created successfully") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 0174c7c854adc199a18078b91e3707c790b9e7cf Mon Sep 17 00:00:00 2001 From: congkechen Date: Thu, 16 Jul 2026 17:54:22 +0800 Subject: [PATCH 19/26] remove ai review prompt file --- .github/ai-review-prompt.md | 78 ------------------------------------- 1 file changed, 78 deletions(-) delete mode 100644 .github/ai-review-prompt.md diff --git a/.github/ai-review-prompt.md b/.github/ai-review-prompt.md deleted file mode 100644 index d05928fd..00000000 --- a/.github/ai-review-prompt.md +++ /dev/null @@ -1,78 +0,0 @@ -你是一个资深代码审查助手。请审查当前 PR 的代码变更,并给出稳定、可复用、可执行的 review 结论。 - -审查范围: -1. 以仓库根目录下的 `pr.diff` 作为审查主范围,只反馈落在 diff 变更中的问题。 -2. 允许结合仓库中的相关上下文辅助判断 diff 是否存在问题,例如被调用方、配置、类型定义、测试、公共函数和上下游调用关系;但不要脱离 diff 单独审查未修改代码。 -3. 不要修改任何文件,不要提交代码。 -4. 只反馈可以从 diff 及其相关仓库上下文中定位和验证的问题;不要基于猜测扩展到未修改代码。 -5. 每个问题必须标注文件路径和行号。行号优先使用 diff 中新增代码对应的目标文件行号;如果只能定位到代码块,请说明“附近行”。 - -审查重点: -1. 正确性:逻辑错误、状态流转错误、条件判断错误、返回值或异常处理错误。 -2. 安全性:凭证泄露、命令注入、路径穿越、不可信输入未校验、权限绕过、敏感信息输出。 -3. 稳定性:边界条件缺失、空值处理、并发或异步时序问题、资源未释放、超时和重试缺失。 -4. 兼容性:公开 API、配置项、持久化数据、CI/workflow 行为的破坏性变更。 -5. 测试有效性:高风险逻辑缺少必要测试,或测试没有覆盖实际风险路径。 -6. 可维护性:仅在影响理解、扩展或长期维护时提出,不输出泛泛的代码风格建议。 - -质量等级定义: -- 🚨 Critical:必须修复的问题。包括安全漏洞、明确的逻辑错误、会导致核心功能失败、数据错误、权限问题、CI 阻塞或线上风险的问题。 -- ⚠️ Warning:建议修复的问题。包括性能隐患、边界条件缺失、异常路径不完整、兼容性风险、测试覆盖不足等可能导致问题但不一定立即失败的情况。 -- 💡 Suggestion:可选优化。包括代码可读性、结构简化、维护性提升、轻微重复等不影响当前正确性的改进建议。 - -输出要求: -1. 使用 Markdown 输出,保持简洁,避免长篇解释。 -2. 先列问题,再给总结。 -3. 按 Critical、Warning、Suggestion 的顺序输出;同级别内按风险从高到低排序。 -4. 合并同类问题:同一根因、同一文件相邻代码、同一修复方式的问题应合并为一条,不要重复展开,也不要用不同表述重复描述同一问题。 -5. 优先输出高价值问题。Critical 和 Warning 不限数量但要合并;Suggestion 最多 2 条,且只有在确实有维护价值时输出。 -6. 每条问题控制在 2-3 句话内:说明问题、影响和修复方向即可,不要分别展开大段“问题/影响/建议”。 -7. 问题位置必须使用反引号包裹的 `文件路径:行号` 或 `文件路径:起始行-结束行` 格式,便于评论自动生成 GitHub 跳转链接。 -8. 如果没有发现明确问题,请直接说明“未发现明显阻塞问题”,不要为了完整性补充低价值建议。 -9. 如果同一段代码同时涉及多个相关风险(如命令注入、路径穿越),优先合并成一条,除非修复方式或影响范围明显不同。 -10. 对测试代码、示例代码、演示代码也按真实执行风险评估;如果这些代码会进入 CI、测试运行路径或被其他开发者复用,不要因为“仅用于示例/测试”而降低问题等级。 -11. 仅当问题离开具体代码片段不易理解时,才附带 3-8 行最小必要代码片段;前后可用 `...` 省略,不要默认给每个问题都附代码块,也不要贴大段代码。 -12. 结论要尽量确定:只有在确实无法从 diff 验证时,才使用“可能”“疑似”等措辞。 - -输出格式: - -## 发现的问题 - -如果存在问题,请按下面格式逐条输出。每条尽量一行位置 + 一段简短说明: - -### 🚨 Critical - -- `文件路径:行号`:问题标题 - - 简要说明具体风险和建议修复方式。 - - 如有必要,可附最小必要代码片段: - ```python - ... - 问题代码 - ... - ``` - -### ⚠️ Warning - -- `文件路径:行号`:问题标题 - - 简要说明具体风险和建议修复方式。 - - 如有必要,可附最小必要代码片段: - ```python - ... - 问题代码 - ... - ``` - -### 💡 Suggestion - -- `文件路径:行号`:问题标题 - - 简要说明维护性影响和可选优化方式。 - -如果某个等级没有问题,可以省略该等级。 - -## 总结 - -用 1-2 句话说明整体风险,以及是否存在必须修复的问题。 - -## 测试建议 - -如果需要补充测试,用 1-2 条说明建议覆盖的场景;如果不需要,请说明“暂无额外测试建议”。 \ No newline at end of file From 9cd6bc367a6f6838ccb18c14bb6f599e51213e5d Mon Sep 17 00:00:00 2001 From: martian <77064664+jinfengliu-688@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:48:19 +0800 Subject: [PATCH 20/26] =?UTF-8?q?Feat:=20tRPC-Agent=E5=AF=B9=E9=BD=90Claud?= =?UTF-8?q?e=20Code=20Plan=20Mod=E8=83=BD=E5=8A=9B=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature: - 参考claude code 的工具设计、提示词设计进行框架plan mod能力的设计与实现。 - 支持模型主动进入plan 模式 & 用户主动选择plan模式。 - 支持agui 解析 toolset能力 BugFix: - 修复agui协议传递state数据无法有效更新问题。 Doc: - 支持plan mod指引文档。 - 单独列出todowrite、task、goal的指引文档。 --- docs/mkdocs/en/goal.md | 157 +++ docs/mkdocs/en/index.md | 1 + docs/mkdocs/en/plan.md | 343 +++++++ docs/mkdocs/en/tool.md | 357 +------ docs/mkdocs/en/tool_task.md | 108 ++ docs/mkdocs/en/tool_todowrite.md | 83 ++ docs/mkdocs/zh/goal.md | 157 +++ docs/mkdocs/zh/index.md | 1 + docs/mkdocs/zh/plan.md | 343 +++++++ docs/mkdocs/zh/tool.md | 359 +------ docs/mkdocs/zh/tool_task.md | 108 ++ docs/mkdocs/zh/tool_todowrite.md | 83 ++ examples/plan_mode/.env | 4 + examples/plan_mode/README.md | 100 ++ examples/plan_mode/agent/__init__.py | 9 + examples/plan_mode/agent/agent.py | 46 + examples/plan_mode/agent/prompts.py | 8 + examples/plan_mode/run_agent_with_agui.py | 132 +++ examples/plan_mode/static/index.html | 777 ++++++++++++++ examples/plan_mode_with_goal_and_task/.env | 4 + .../plan_mode_with_goal_and_task/README.md | 97 ++ .../agent/__init__.py | 9 + .../agent/agent.py | 46 + .../agent/prompts.py | 7 + .../run_agent_with_agui.py | 127 +++ .../static/index.html | 903 ++++++++++++++++ mkdocs.yml | 8 + tests/plan_mode/__init__.py | 5 + tests/plan_mode/test_plan_mode.py | 962 ++++++++++++++++++ tests/server/ag_ui/_core/test_agui_agent.py | 108 ++ .../ag_ui/_core/test_session_manager.py | 29 + trpc_agent_sdk/plan_mode/__init__.py | 61 ++ trpc_agent_sdk/plan_mode/_base.py | 70 ++ trpc_agent_sdk/plan_mode/_controller.py | 296 ++++++ trpc_agent_sdk/plan_mode/_helpers.py | 166 +++ trpc_agent_sdk/plan_mode/_lock.py | 81 ++ .../plan_mode/_long_running_tools.py | 254 +++++ trpc_agent_sdk/plan_mode/_models.py | 74 ++ trpc_agent_sdk/plan_mode/_plan_toolset.py | 60 ++ trpc_agent_sdk/plan_mode/_prompt.py | 367 +++++++ trpc_agent_sdk/plan_mode/_setup.py | 88 ++ trpc_agent_sdk/plan_mode/_store.py | 252 +++++ .../plan_mode/_update_plan_content_tool.py | 68 ++ .../server/ag_ui/_core/_agui_agent.py | 96 +- .../server/ag_ui/_core/_session_manager.py | 2 +- 45 files changed, 6699 insertions(+), 717 deletions(-) create mode 100644 docs/mkdocs/en/goal.md create mode 100644 docs/mkdocs/en/plan.md create mode 100644 docs/mkdocs/en/tool_task.md create mode 100644 docs/mkdocs/en/tool_todowrite.md create mode 100644 docs/mkdocs/zh/goal.md create mode 100644 docs/mkdocs/zh/plan.md create mode 100644 docs/mkdocs/zh/tool_task.md create mode 100644 docs/mkdocs/zh/tool_todowrite.md create mode 100644 examples/plan_mode/.env create mode 100644 examples/plan_mode/README.md create mode 100644 examples/plan_mode/agent/__init__.py create mode 100644 examples/plan_mode/agent/agent.py create mode 100644 examples/plan_mode/agent/prompts.py create mode 100644 examples/plan_mode/run_agent_with_agui.py create mode 100644 examples/plan_mode/static/index.html create mode 100644 examples/plan_mode_with_goal_and_task/.env create mode 100644 examples/plan_mode_with_goal_and_task/README.md create mode 100644 examples/plan_mode_with_goal_and_task/agent/__init__.py create mode 100644 examples/plan_mode_with_goal_and_task/agent/agent.py create mode 100644 examples/plan_mode_with_goal_and_task/agent/prompts.py create mode 100644 examples/plan_mode_with_goal_and_task/run_agent_with_agui.py create mode 100644 examples/plan_mode_with_goal_and_task/static/index.html create mode 100644 tests/plan_mode/__init__.py create mode 100644 tests/plan_mode/test_plan_mode.py create mode 100644 trpc_agent_sdk/plan_mode/__init__.py create mode 100644 trpc_agent_sdk/plan_mode/_base.py create mode 100644 trpc_agent_sdk/plan_mode/_controller.py create mode 100644 trpc_agent_sdk/plan_mode/_helpers.py create mode 100644 trpc_agent_sdk/plan_mode/_lock.py create mode 100644 trpc_agent_sdk/plan_mode/_long_running_tools.py create mode 100644 trpc_agent_sdk/plan_mode/_models.py create mode 100644 trpc_agent_sdk/plan_mode/_plan_toolset.py create mode 100644 trpc_agent_sdk/plan_mode/_prompt.py create mode 100644 trpc_agent_sdk/plan_mode/_setup.py create mode 100644 trpc_agent_sdk/plan_mode/_store.py create mode 100644 trpc_agent_sdk/plan_mode/_update_plan_content_tool.py diff --git a/docs/mkdocs/en/goal.md b/docs/mkdocs/en/goal.md new file mode 100644 index 00000000..c2e12a85 --- /dev/null +++ b/docs/mkdocs/en/goal.md @@ -0,0 +1,157 @@ +## Goal Tool Family (Persistent Session Goal) + +`GoalToolSet` exposes three tools — `create_goal`, `get_goal`, `update_goal` — aligned with Claude Code **Session Goal** capabilities. Unlike `TodoWriteTool` (multi-item checklist) and `TaskToolSet` (multi-task board), Goal maintains **at most one** persistent objective per session branch: while status is `active`, a response that **looks like a final answer** does **not** mean the work is done — the model must keep working or explicitly call `update_goal('complete' | 'blocked')`. + +The goal is serialized as a **single JSON blob** (`GoalRecord`) in `tool_context.state["goal[:]"]`, surviving across `Runner.run_async` calls. Beyond the three model tools, the full capability requires `setup_goal()` to mount **enforcement callbacks** (`before_model` / `after_model`) that intercept premature final responses and re-run within the **same invocation**. + +### Features + +- **Single-goal contract**: one `GoalRecord` per branch (`objective` + three states `active` / `complete` / `blocked`); `complete` / `blocked` are **irreversible** terminal states +- **Cross-turn persistence**: persisted via function-response state deltas; **do not** use the `temp:` prefix +- **Sub-agent isolation**: state key appends `:` +- **Enforced completion**: while `active`, `after_model` detects premature finals (no tool call, visible text, non-partial), suppresses them, and re-runs in the same invocation; `before_model` injects a user-role nudge +- **Fail-open budget**: after `max_retries` (default 3) interceptions, the final response is allowed so the loop cannot spin forever; counters live in invocation-scoped `agent_context.metadata` and are not persisted +- **Two creation paths**: + - **Model side**: `create_goal(objective=...)` — LLM creates after judging a multi-step task + - **Host side**: `start_goal(session_service, ...)` — application writes the goal before the first turn; the model does not call `create_goal` +- **Layered prompt guidance**: `DEFAULT_GUIDANCE` injected into system instruction via `before_model` when `inject_guidance=True`; hard rules enforced by store validation + callbacks +- **Concurrency safety**: `_GoalToolBase` wraps load → mutate → save in `goal_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` + +### Relationship to Todo / Task + +| Dimension | `TodoWriteTool` | `TaskToolSet` | Goal Tool Family | +| --- | --- | --- | --- | +| Granularity | Multi-item checklist | Multi-task board + deps | **Single** session objective | +| Update style | Full-list replace | Incremental by `taskId` | `create_goal` / `update_goal` | +| Can finish while incomplete? | Prompt guidance | Prompt guidance | **Callback enforcement** | +| State key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | +| Typical use | Step visibility, short lists | Long boards, dependencies | Whether the whole job is truly done | + +> Todo / Task handle **step decomposition**; Goal handles the **overall completion contract**. They can be combined, but avoid mounting too many planning tools at once. + +### GoalOptions Constructor Parameters + +Configure via `setup_goal(agent, GoalOptions(...))`: + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"goal"` | State key prefix; do not use `temp:` | +| `inject_guidance` | `bool` | `True` | Inject `DEFAULT_GUIDANCE` into system instruction in `before_model` | +| `guidance` | `str` | `DEFAULT_GUIDANCE` | Long guidance text (serial goal-tool calls, etc.) | +| `max_retries` | `int` | `3` | Same-invocation budget for intercepting premature finals; fail-open when exhausted | +| `nudge_template` | `str` | `DEFAULT_NUDGE` | User-role reminder after interception; supports `{attempt}` / `{max_retries}` / `{objective}` | +| `on_retry` | `Callable[[RetryEvent], None]` | `None` | Observability callback on each interception or budget exhaustion | + +Mounting only `GoalToolSet()` without enforcement gives model-facing tools but **not** "no final while active". + +### LLM Parameters for the Three Tools + +**`create_goal`** + +| Parameter | Required | Description | +|------|------|------| +| `objective` | Yes | Completion criteria — what "done" concretely means | + +Success: `{message, goal}`; if an `active` goal already exists: `{error: "INVALID_STATE: ..."}`. + +**`get_goal`** + +No parameters. With a goal: `{message, goal}`; without: `{message: "No session goal is set."}`. + +**`update_goal`** + +| Parameter | Required | Description | +|------|------|------| +| `status` | Yes | `complete` (objective met) or `blocked` (same blocker repeats; cannot proceed without user input) | + +Success: `{message, goal}`; no active goal or already terminal: `{error: "INVALID_STATE: ..."}`. + +**`GoalRecord` fields** (persisted with camelCase JSON aliases): + +| Field | Description | +|------|------| +| `id` | Server-assigned uuid | +| `objective` | Completion criteria text | +| `status` | `active` / `complete` / `blocked` | +| `createdAtUnix` / `updatedAtUnix` | Created / last-updated time (unix seconds) | +| `terminalAtUnix` | Time entered a terminal state (optional) | + +### Enforcement Workflow + +```text +Model outputs final text (no tool call, goal still active) + ↓ +after_model classifies as premature final + ↓ +Suppress final (not committed as answer), retry_count += 1 +before_model injects nudge, same invocation continues agent loop + ↓ +retry_count >= max_retries → fail-open, on_retry(reason="exhausted") +``` + +Interception condition (`_is_premature_final`): non-partial, no error, visible text in content, and **no** `function_call` / `function_response`. + +### Usage + +Recommended one-line mount of tools + callbacks: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal + +def on_retry(event: RetryEvent) -> None: + if event.reason == "blocked": + print(f"Premature final intercepted (attempt {event.attempt_number}/{event.max_retries})") + +agent = LlmAgent( + name="goal_agent", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="Use goal tools to track completion for multi-step engineering tasks.", + tools=[...], # Business tools, e.g. BashTool / WriteTool +) +setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) +``` + +Host pre-injects a goal before the first turn (model does not call `create_goal`): + +```python +from trpc_agent_sdk.tools.goal_tools import start_goal + +goal = await start_goal( + session_service, + app_name="my_app", + user_id="user_1", + session_id=session_id, + objective="Create notes/ with summary.txt and example.py in the current directory", + agent_name=agent.name, # Match LlmAgent.name for branch isolation +) +``` + +Read back the persisted goal (REST / audit / demo wrap-up): + +```python +from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal + +goal = get_goal_record(session, branch=agent.name) +print(render_goal(goal)) +# ✅ Goal [complete] +# objective: ... +# created: 1782893110 +# terminal: 1782893116 +``` + +### Goal Tool Family Best Practices + +- **Use `setup_goal`, not `GoalToolSet` alone**: only callbacks enforce "no final while active" +- **Model vs host**: slash commands, `/goal`, config-driven tasks → `start_goal()`; let the model judge multi-step work → `create_goal` +- **One goal tool per response**: `DEFAULT_GUIDANCE` requires serial semantics; do not call `create_goal` and `update_goal` in the same turn +- **Use `blocked` sparingly**: only when the same blocker repeats across attempts and user input or external state change is required; do not mark blocked because work is hard, slow, or incomplete +- **Observability**: use `on_retry` to log premature-final interceptions and budget exhaustion when tuning prompt or `max_retries` +- **Division of labor with Todo / Task**: Todo / Task show steps and dependencies; Goal constrains whether the whole job is truly finished + +### Goal Tool Family Complete Example + +| Example | Description | +| --- | --- | +| [examples/goal_tools](../../../examples/goal_tools/) | Case 1: model `create_goal`; Case 2: host `start_goal` pre-injection; demonstrates enforcement interception and `update_goal(complete)` | diff --git a/docs/mkdocs/en/index.md b/docs/mkdocs/en/index.md index 3110ecb1..99848856 100644 --- a/docs/mkdocs/en/index.md +++ b/docs/mkdocs/en/index.md @@ -16,6 +16,7 @@ Welcome to the English documentation for tRPC-Agent-Python. - [Memory](./memory.md): Store and retrieve long-term memories. - [Knowledge](./knowledge.md): Build RAG workflows with LangChain components. - [Multi-Agent](./multi_agents.md): Compose agents for complex workflows. +- [Plan Mode](./plan.md): Design-then-implement workflow with write gate and HITL approval. - [Evaluation](./evaluation.md): Evaluate agent behavior and response quality. For source code and examples, see the [GitHub repository](https://github.com/trpc-group/trpc-agent-python). diff --git a/docs/mkdocs/en/plan.md b/docs/mkdocs/en/plan.md new file mode 100644 index 00000000..699a4fd0 --- /dev/null +++ b/docs/mkdocs/en/plan.md @@ -0,0 +1,343 @@ +# Plan Mode + +Plan Mode adds a **design-then-implement** workflow to `LlmAgent`: during planning, the model may only use read-only tools and draft a plan document; write-capable tools stay blocked until a human approves the plan via Human-In-The-Loop (HITL). + +It pairs naturally with `SpawnSubAgentTool` (`EXPLORE_AGENT` / `PLAN_AGENT`) for codebase research and solution design, and with `TodoWriteTool` or `TaskToolSet` after approval to track implementation progress. + +## What It Solves + +A typical coding agent may start editing files immediately and change direction mid-flight. Plan Mode splits the workflow into two phases: + +1. **Planning** — explore with read-only tools (`Read` / `Grep` / `Glob`), spawn read-only sub-agents (`Explore` / `Plan`), write the plan via `update_plan_content`, and ask clarifying questions. Side-effect tools (`Write` / `Edit` / `Bash`, `todo_write`, `task_create`, etc.) are gated. +2. **Implementation** — after human approval, write tools unlock and the model implements against the approved plan. + +Three human-touch points — `enter_plan_mode`, `exit_plan_mode`, and `ask_user_question` — are `LongRunningFunctionTool`s. Execution pauses until the host resumes with a tool function response. See [Human in the Loop](./human_in_the_loop.md) for the general HITL mechanism. + +## Why It Matters + +One of the biggest risks for a coding agent is not writing buggy code — it is **building the wrong thing correctly**. When a user says "refactor the auth module", the agent might choose JWT while the user had OAuth2 in mind. If the agent starts implementing immediately, a dozen files may already be changed before the mismatch is discovered. + +Plan Mode addresses **intent alignment**: before any code is modified, the agent explores the codebase, drafts a plan, and obtains human approval. This is not a simple "ask before doing" flag — it is a full state machine involving: + +- Write-tool gate (permission-level behavioural constraint) +- Plan document persistence (alignment artefact) +- Workflow prompt injection +- Two HITL checkpoints (enter + exit) +- UI Plan toggle integration (`agent_mode=plan`) + +## Solution: Four-Step Closed Loop + +Plan Mode introduces a **read-only phase** in the conversation, closed by two long-running tools — `enter_plan_mode` and `exit_plan_mode`: + +| Step | Name | Behaviour | +| --- | --- | --- | +| 1 | **Enter Plan Mode** | The model decides planning is needed, or the user selects Plan Mode in the UI; calls `enter_plan_mode` (HITL: user must confirm entry) | +| 2 | **Exploration** | Permission constraint is read-only: only `Read` / `Grep` / `Glob`, read-only sub-agents (`Explore` / `Plan`), and `update_plan_content` are allowed; all writes are intercepted in `before_tool` | +| 3 | **Submit for approval** | After exploration, calls `exit_plan_mode` and submits the plan document for human review (HITL: approve or reject) | +| 4 | **Resume execution** | On approval, status becomes `approved`, the write gate lifts, and the agent implements with full tool permissions | + +```mermaid +flowchart LR + A["① enter_plan_mode
Enter Plan Mode"] --> B["② Exploration
Read-only tools"] + B --> C["update_plan_content
Draft plan"] + C --> D["③ exit_plan_mode
Submit for review"] + D --> E{Approved?} + E -->|Reject| C + E -->|Approve| F["④ approved
Write tools unlocked"] + F --> G["Implement approved plan"] +``` + +```text +User / model HITL Read-only gate HITL Full access + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +enter_plan_mode ──approve──▶ exploring/drafting ──draft──▶ exit_plan_mode ──approve──▶ approved → Write/Edit/... +``` + +## Key Design Decisions + +From an engineering perspective, Plan Mode embodies three core decisions (aligned with the Claude Code Plan Mode philosophy; mapped to tRPC-Agent-Python as follows): + +### 1. Permission mode as behavioural constraint + +Once in Plan Mode, the tool surface is restricted to read-only operations — **not** via a prompt like "please do not edit files", but by intercepting write tool calls in `before_tool` before execution (`PLAN_MODE_GATE` error). Sub-agents are constrained too: `spawn_subagent` only allows `Explore` / `Plan` archetypes so the parent cannot delegate write-capable tool surfaces. + +### 2. Plan document as alignment artefact + +The plan is not ephemeral chat text — it is a persisted **Markdown artefact** (`PlanRecord.content`) stored in session state (`plan[:branch]`) and surviving across `Runner.run_async` calls. Reviewers can edit the plan on approval (pass `content` in the `exit_plan_mode` resume payload); AG-UI exposes it via `STATE_SNAPSHOT` for a live plan panel, keeping remote sessions and local UI aligned. + +### 3. State machine, not a boolean flag + +Plan Mode is not a simple `isPlanMode` switch. It is a full transition chain (`PlanStatus`) — enter, explore, draft, approve, exit, recover — where each transition has side effects: + +| Transition | Side effect | +| --- | --- | +| `enter_plan_mode` approved | Create `PlanRecord`, enter `exploring`, enable write gate | +| `update_plan_content` | `exploring` → `drafting`, append/replace plan body | +| `exit_plan_mode` | → `pending_approval`, pause for review | +| Approve | → `approved`, disable write gate, release plan lock | +| Reject | → `drafting`, allow revision and resubmission | + +## Architecture + +``` +orchestrator (LlmAgent + setup_plan) +├── business tools (e.g. FileToolSet, SpawnSubAgentTool, TodoWriteTool) +└── PlanToolSet (mounted by setup_plan) + ├── enter_plan_mode (LongRunningFunctionTool — HITL) + ├── update_plan_content + ├── exit_plan_mode (LongRunningFunctionTool — HITL) + └── ask_user_question (LongRunningFunctionTool — HITL) + +_PlanCallbacks (before_model / before_tool) +├── inject plan / awareness prompts +├── process HITL resume payloads +├── auto-enter when session state signals UI Plan toggle +└── block write tools while plan gate is active +``` + +- The plan document is persisted in the **main agent session** at `state["plan[:]"]` (default prefix `plan`). +- Spawned sub-agents return text only; they do not mutate the parent's plan state. + +## State Machine + +| Status | Meaning | Write gate | +| --- | --- | --- | +| `pending_enter` | Waiting for human to confirm entering Plan Mode | Active | +| `exploring` | Read-only exploration | Active | +| `drafting` | Plan content being written | Active | +| `pending_approval` | Plan submitted, awaiting human review | Active | +| `approved` | Human approved; implementation may begin | **Off** | + +Typical flow: + +```text +enter_plan_mode (HITL) → exploring → update_plan_content → drafting + → exit_plan_mode (HITL) → pending_approval + → approved → implement with write tools +``` + +If the human rejects at `exit_plan_mode`, status returns to `drafting` for revision. + +## Features + +- **Session-scoped plan artifact** — `PlanRecord` serialised as JSON in session state; survives across `Runner.run_async` calls +- **Write gate** — `before_tool` blocks tools in `DEFAULT_WRITE_TOOL_NAMES` while the gate is active +- **Sub-agent restrictions** — `spawn_subagent` limited to read-only archetypes (`Explore`, `Plan`); `dynamic_subagent` must explicitly restrict `tools` to a read-only subset +- **Prompt injection** — awareness prompt when no active plan; full Plan Mode prompt while gate is active +- **HITL tools** — three long-running tools pause for human input; resume handled in `before_model` +- **UI auto-enter** — when session state `agent_mode=plan`, auto-enters Plan Mode and hides `enter_plan_mode` from the tool schema +- **Idempotent HITL resume** — only the latest user turn's function responses are applied; stale rejections in history are not replayed +- **Concurrency safety** — plan tools use `plan_store_lock` (per session + branch) for load → mutate → save + +## Comparison with Todo / Task / Goal + +| Dimension | TodoWriteTool | TaskToolSet | Goal | **Plan Mode** | +| --- | --- | --- | --- | --- | +| Purpose | Step checklist | Task board + dependencies | Session completion contract | **Design doc + approval before writes** | +| Human approval | No | No | No (enforcement only) | **Yes (enter + exit HITL)** | +| Blocks write tools | No (prompt only) | No | No | **Yes (code-enforced gate)** | +| State key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | `plan[:branch]` | +| Typical use | Track steps after plan | Long boards, dependencies | "Is the whole job done?" | **Explore → draft → approve → implement** | + +> Todo / Task track execution steps; Goal enforces completion; **Plan Mode gates side effects until a human signs off on the design.** + +## PlanOptions + +Configure via `setup_plan(agent, PlanOptions(...))`: + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `state_key_prefix` | `str` | `"plan"` | Session state key prefix; do not use `temp:` | +| `plan_prompt` | `str` | `DEFAULT_PLAN_MODE_PROMPT` | Injected while gate is active | +| `awareness_prompt` | `str` | `DEFAULT_PLAN_AWARENESS_PROMPT` | Injected when no active plan | +| `write_tool_names` | `FrozenSet[str]` | `DEFAULT_WRITE_TOOL_NAMES` | Tool names blocked during gate; extend for MCP / custom write tools | +| `inject_prompt` | `bool` | `True` | Inject plan prompt when gate active | +| `inject_awareness` | `bool` | `True` | Inject awareness prompt otherwise | +| `force_enter_plan_state_key` | `Optional[str]` | `"agent_mode"` | Session key for UI-driven auto-enter; `None` disables | +| `force_enter_plan_state_value` | `str` | `"plan"` | Value that triggers auto-enter | +| `on_approval` | `Callable` | `None` | Callback on `exit_plan_mode` approve / reject | +| `readonly_subagent_types` | `FrozenSet[str]` | `{"Explore", "Plan"}` | Allowed `spawn_subagent` archetypes during gate | +| `readonly_tool_names` | `FrozenSet[str]` | `{"Read", "Grep", "Glob", "webfetch", "websearch"}` | Allowed `dynamic_subagent` tool subset | + +> Call `setup_plan()` **once** per agent. Repeated calls append duplicate toolsets and callbacks. + +## Tools + +### `enter_plan_mode` (LongRunningFunctionTool) + +Request human confirmation before entering Plan Mode. + +| Parameter | Required | Description | +| --- | --- | --- | +| `objective` | Yes | Short description of what to plan | + +Returns `{status: "pending_enter", message, objective, plan_id, approval_id, ...}`. Resume with `{status: "approved"}` or `{status: "rejected", reviewer_note?: "..."}`. + +Omitted from the tool schema when `agent_mode=plan` is set or a plan gate is already active. + +### `update_plan_content` + +Append or replace Markdown plan text. + +| Parameter | Required | Description | +| --- | --- | --- | +| `content` | Yes | Plan Markdown | +| `mode` | No | `"append"` (default) or `"replace"` | + +Moves status `exploring` → `drafting` on first write. + +### `exit_plan_mode` (LongRunningFunctionTool) + +Submit the plan for human approval. + +| Parameter | Required | Description | +| --- | --- | --- | +| `summary` | No | Short summary for the reviewer | + +Requires non-empty plan content. Returns `{status: "pending_approval", content, preview, ...}`. Resume with: + +```json +{"status": "approved"} +``` + +or + +```json +{"status": "rejected", "reviewer_note": "Add error handling section"} +``` + +Optional `content` on approve applies an edited plan from the reviewer. + +### `ask_user_question` (LongRunningFunctionTool) + +Structured clarification during an active plan. + +| Parameter | Required | Description | +| --- | --- | --- | +| `question` | Yes | Question text | +| `options` | No | Suggested answers | + +Resume with `{status: "answered", question_id: , answer: ""}`. + +## Write Gate Rules + +While `PlanRecord.is_gate_active()` (`exploring`, `drafting`, or `pending_approval`): + +| Tool kind | Rule | +| --- | --- | +| Plan tools (`enter_plan_mode`, `update_plan_content`, `exit_plan_mode`, `ask_user_question`) | Always allowed (with state checks) | +| `spawn_subagent` | Only `Explore` and `Plan` archetypes | +| `dynamic_subagent` | Only if `tools` is an explicit subset of `readonly_tool_names` | +| Names in `write_tool_names` | Blocked with `PLAN_MODE_GATE` error | + +Default blocked names: `Write`, `Edit`, `Bash`, `todo_write`, `task_create`, `task_update`, `create_goal`, `update_goal`. + +## HITL Resume (Host Integration) + +On resume, submit a user message whose parts include a `function_response` for the paused tool. `before_model` calls `process_hitl_function_response` and replaces the raw host payload with the state machine's standardised result (message + full plan dump) so the model sees the same shape as a normal tool return. + +**Enter Plan Mode — approve:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="enter_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**Exit Plan Mode — approve:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**Ask user question — answer:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="ask_user_question", + response={"status": "answered", "question_id": 1, "answer": "Use PostgreSQL"}, + ))], +) +``` + +With [AG-UI](./agui.md), long-running tools do not emit `TOOL_CALL_RESULT` until resumed; the host sends the function response on the next run. `STATE_SNAPSHOT` events expose `plan:` for live plan panels. + +## Entry Methods + +Plan Mode can be entered via two paths, differing in who triggers entry and whether `pending_enter` HITL is required: + +| Dimension | Method 1: model calls `enter_plan_mode` | Method 2: session state signal (UI-driven) | +| --- | --- | --- | +| Trigger | Model decides planning is needed | Host / UI writes session state | +| Initial status | `pending_enter` → HITL confirm → `exploring` | Enters `exploring` directly | +| Entry confirmation | Yes | No | +| `enter_plan_mode` tool | Exposed normally (when no gate) | Hidden from schema; calls return `PLAN_MODE_GATE` | + +### Method 1: model calls `enter_plan_mode` (HITL confirmation) + +**Flow:** + +1. The model decides the task needs planning (guided by the awareness prompt) +2. Calls `enter_plan_mode(objective="...")` +3. Status becomes `pending_enter`; execution pauses; the host shows a confirmation card +4. On user approval, execution resumes into `exploring` and the write gate activates + +### Method 2: session state signal forces auto-enter (UI-driven) + +**Flow:** + +1. The host writes `agent_mode = "plan"` into session state (defaults; customise via `PlanOptions`) +2. On the next invocation, `before_model` triggers `_ensure_forced_plan` +3. Internally calls `apply_enter`, **skipping** `pending_enter`, entering `exploring` directly +4. `PlanToolSet` hides `enter_plan_mode` from the tool schema to avoid redundant HITL + +**AG-UI example (`examples/plan_mode/static/index.html`):** + +When the user toggles Plan mode in the page, each run writes `agent_mode` into the AG-UI request `state` field: + +```javascript +// Must match PlanOptions defaults +const FORCE_ENTER_PLAN_STATE_KEY = "agent_mode"; +const FORCE_ENTER_PLAN_STATE_VALUE = "plan"; + +function buildRunState() { + return { + [FORCE_ENTER_PLAN_STATE_KEY]: + agentMode === "plan" ? FORCE_ENTER_PLAN_STATE_VALUE : "agent", + }; +} + +// RunAgentInput.state = buildRunState() +``` + +After switching to Plan mode, the next user message auto-enters `exploring` without an `enter_plan_mode` confirmation card. See [examples/plan_mode](../../../examples/plan_mode/) for the full demo. + +## Best Practices + +- **Mount read-only exploration first** — `FileToolSet` + `SpawnSubAgentTool(EXPLORE_AGENT, PLAN_AGENT)` before `setup_plan` +- **Extend `write_tool_names`** — if the agent mounts MCP, Skills, or custom write tools, add their `tool.name` values to `PlanOptions.write_tool_names` +- **One `setup_plan` per agent** — avoid duplicate callbacks +- **Pair with AG-UI for HITL** — browser cards for enter / approve / questions are easier than CLI simulation +- **After approval** — use `todo_write` or `task_create` to track implementation; Plan Mode does not replace step tracking +- **Trivial tasks** — awareness prompt allows skipping Plan Mode with an explicit reason; do not force plan for one-line fixes + +## Full Example + +| Example | Description | +| --- | --- | +| [examples/plan_mode](../../../examples/plan_mode/) | Orchestrator + `setup_plan` + AG-UI browser demo with live plan panel and HITL cards | + +Install AG-UI extras: `pip install -e '.[ag-ui]'`, configure `examples/plan_mode/.env`, then run `python3 run_agent_with_agui.py`. diff --git a/docs/mkdocs/en/tool.md b/docs/mkdocs/en/tool.md index b14b235b..6c20ebac 100644 --- a/docs/mkdocs/en/tool.md +++ b/docs/mkdocs/en/tool.md @@ -33,9 +33,9 @@ Agents dynamically use tools through the following steps: | [Streaming Tools](#streaming-tools) | Real-time preview of long text generation | Use StreamingFunctionTool | Code generation, document writing | | [WebFetchTool](#webfetchtool) | Fetch and textify a single public URL | Instantiate WebFetchTool and add to tools | Documentation pages, RFCs, changelogs, news | | [WebSearchTool](#websearchtool) | Public web search engine retrieval | Instantiate WebSearchTool and add to tools | Real-time news, releases, fact/definition lookups | -| [TodoWriteTool](#todowritetool-task-checklist-tool) | Multi-step task planning and progress tracking (full-list replace) | Mount `TodoWriteTool` | Short checklists, no dependency graph, token-insensitive | -| [Task Tool Family](#task-tool-family-structured-task-board) | Structured task board (incremental updates by id + dependencies) | Mount `TaskToolSet` | Long boards, cross-turn tracking, `blockedBy` dependencies | -| [Goal Tool Family](#goal-tool-family-persistent-session-goal) | Single persistent session goal + enforced completion | `setup_goal(agent)` | Cross-turn objectives, host-set goals, no premature finals | +| [TodoWriteTool](./tool_todowrite.md) | Multi-step task planning and progress tracking (full-list replace) | Mount `TodoWriteTool` | Short checklists, no dependency graph, token-insensitive | +| [Task Tool Family](./tool_task.md) | Structured task board (incremental updates by id + dependencies) | Mount `TaskToolSet` | Long boards, cross-turn tracking, `blockedBy` dependencies | +| [Goal Tool Family](./goal.md) | Single persistent session goal + enforced completion | `setup_goal(agent)` | Cross-turn objectives, host-set goals, no premature finals | | [Agent Code Executor](./code_executor.md) | Automatic code generation and execution scenarios, data processing scenarios | Configure CodeExecutor | Automatic API invocation, tabular data processing | --- @@ -2848,354 +2848,3 @@ The example builds four independent Agents and covers the following scenarios: - **Google freshness Agent** (`google_raw_agent`, `dateRestrict=m6` + `dedup_urls=False`): keeps only results indexed within the last 6 months, suitable for "latest / what's new" queries --- - -## TodoWriteTool (Task Checklist Tool) - -`TodoWriteTool` is the framework's built-in **structured task checklist tool**, aligned with Claude Code / DeepAgents `TodoWrite` semantics: the model sends the **complete, updated list** in a single `todo_write` call; the tool validates it, fully replaces the previous list, and persists it to session-level state so plans and progress survive across `Runner.run_async` invocations. - -Best for **fewer steps, no explicit dependency edges, and simple implementation**. If you need server-assigned ids, incremental `taskId` patches, or `blockedBy` / `blocks` dependency orchestration, use the [Task Tool Family](#task-tool-family-structured-task-board) below instead. - -### Features - -- **Full-list replace**: each call passes the complete `todos` array; the new list **fully overwrites** the old one (no smart merge). The only valid way to clear is an explicit `todos: []` -- **Session-level persistence**: the checklist is serialized to JSON in `tool_context.state["todos[:]"]` (default prefix `todos`; **do not** use `temp:` — that prefix is stripped by `BaseSessionService` and is not persisted) -- **Sub-agent isolation**: the state key appends `:` so parent / child agents maintain separate lists -- **Hard contract validation (code-enforced)**: non-empty `content` / `activeForm`, at most one `in_progress`, globally unique `content`; violations return `INVALID_ARGS` / `INVALID_TODOS` -- **Layered prompt guidance**: `DEFAULT_TODO_PROMPT` is auto-injected into the system instruction via `process_request`, separate from hard contracts -- **Structured diff in responses**: on success returns `{message, todos, oldTodos}` for front-end / CLI rendering -- **Optional policy hooks**: read-only `nudge_hooks` can append strategy hints to `message` (must not modify the list) -- **Auto-clear when all done**: with `clear_on_all_done=True` (default), an all-`completed` list is persisted as empty to avoid stale accumulation - -### TodoWriteTool Parameters - -| Parameter | Type | Default | Description | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"todos"` | State key prefix; do not use `temp:` | -| `clear_on_all_done` | `bool` | `True` | Clear persisted list when all items are `completed` | -| `default_nudge` | `str` | built-in text | Base hint appended on every successful response | -| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | Read-only policy hook list | -| `filters_name` / `filters` | — | `None` | Filters forwarded to `BaseTool` | - -**LLM call parameters** (`todo_write`): - -| Parameter | Type | Required | Description | -|------|------|------|------| -| `todos` | `array` | Yes | Full list; each item has `content` (imperative), `activeForm` (in-progress label), `status` (`pending` / `in_progress` / `completed`) | - -**Successful response fields**: - -| Field | Type | Description | -|------|------|------| -| `message` | `str` | Base nudge + hook-appended text | -| `todos` | `array` | Persisted current list | -| `oldTodos` | `array \| null` | Previous list (`null` on first write) | - -### Usage - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TodoWriteTool - -agent = LlmAgent( - name="todo_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="You are a planning assistant; use todo_write for multi-step tasks.", - tools=[TodoWriteTool()], -) -``` - -Read back the persisted checklist (REST / audit): - -```python -from trpc_agent_sdk.tools import get_todos, render_todos - -todos = get_todos(session, branch=agent.name) -print(render_todos(todos)) # ✅ / 🔄 / ⬜ plain-text checklist -``` - -### TodoWriteTool vs Task Tool Family - -| Dimension | `TodoWriteTool` | `TaskToolSet` | -| --- | --- | --- | -| Tool count | 1 (`todo_write`) | 4 (`task_create` / `task_update` / `task_get` / `task_list`) | -| Update style | Full-list replace | Incremental patch by `taskId` | -| Item identity | `content` (unique key) | `id` (server-assigned) | -| Dependencies | None | `blockedBy` / `blocks`; upstream `completed` auto-unblocks | -| State key | `todos[:branch]` | `tasks[:branch]` | -| Parallel tool calls | Full-list overwrite, natural last-write-wins | `task_store_lock` serializes RMW | - -> **Mount one or the other**; mounting both tends to confuse the model. - -### TodoWriteTool Complete Example - -See [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py): multiple turns in one session — plan → complete items step by step — with `get_todos` reading back the persisted list after each turn. - ---- - -## Task Tool Family (Structured Task Board) - -`TaskToolSet` exposes four tools — `task_create`, `task_update`, `task_get`, `task_list` — aligned with Claude Code v2.1.142+ structured Task capabilities. Unlike `TodoWriteTool`'s full-list replace, the Task family uses **incremental updates by server-assigned `id`**: creation returns an id; later `task_update` patches status, fields, or dependency edges locally. - -The entire board is serialized as a **single JSON blob** in `tool_context.state["tasks[:]"]`, surviving across turns. `highwatermark` records the highest id ever assigned; soft-deleted tasks (`status: deleted`) **never reuse ids**. - -### Features - -- **Incremental updates**: `task_create` assigns ids; `task_update` patches by `taskId` without resending the whole board -- **Dependency orchestration**: `addBlockedBy` / `removeBlockedBy` (and `addBlocks` / `removeBlocks`) maintain bidirectional edges; upstream `completed` removes ids from downstream `blockedBy` and returns `unblocked` -- **Token optimization**: `task_list` returns summaries only (omits `description`); use `task_get` for full detail -- **Hard contract validation**: non-empty `subject`, valid status, existing dependency refs, **acyclic** graph (`detect_cycle`), default **at most one `in_progress`** (`enforce_single_in_progress`, can disable) -- **Concurrency safety**: `_TaskToolBase` wraps load → mutate → save in `task_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` -- **Auto prompt injection**: `DEFAULT_TASK_PROMPT` injected once when multiple tools are mounted - -### TaskToolSet Constructor Parameters - -| Parameter | Type | Default | Description | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"tasks"` | State key prefix; do not use `temp:` | -| `enforce_single_in_progress` | `bool` | `True` | Reject a second `in_progress` when one already exists | -| `inject_prompt` | `bool` | `True` | Inject `DEFAULT_TASK_PROMPT` into system instruction | - -### LLM Parameters for the Four Tools - -**`task_create`** - -| Parameter | Required | Description | -|------|------|------| -| `subject` | Yes | Short imperative title | -| `description` | No | Free-text detail | -| `activeForm` | No | In-progress label | -| `metadata` | No | Extension key-value map | - -Returns `{task: {id, subject}, message}`. - -**`task_update`** - -| Parameter | Required | Description | -|------|------|------| -| `taskId` | Yes | Task id to update | -| `status` | No | `pending` / `in_progress` / `completed` / `deleted` | -| `subject` / `description` / `activeForm` / `owner` / `metadata` | No | Scalar field patches | -| `addBlockedBy` / `removeBlockedBy` | No | Upstream dependency id lists | -| `addBlocks` / `removeBlocks` | No | Downstream blocked-id lists | - -Returns `{task, unblocked, message}`; `unblocked` lists pending task ids unblocked by this completion. - -**`task_get`**: `taskId` (required) → full record including `description`. - -**`task_list`**: optional `includeDeleted`; returns `{tasks, stats}` with summaries (no `description`). - -**Common error codes**: `INVALID_ARGS`, `INVALID_DEPENDENCY`, `INVALID_STATUS`, `NOT_FOUND`. - -### Usage - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TaskToolSet - -agent = LlmAgent( - name="task_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="Use task_create / task_update to maintain the board for multi-step projects.", - tools=[TaskToolSet()], - # With parallel_tool_calls=True, concurrent task tools on the same board are serialized by task_store_lock -) -``` - -Read back the persisted board (REST / audit / demo wrap-up): - -```python -from trpc_agent_sdk.tools import get_task_store, render_task_list - -store = get_task_store(session, branch=agent.name) -print(render_task_list(store)) -# ✅ #1 completed -# 🔄 #2 in progress -# ⬜ #3 pending (blocked by: 2) -``` - -### Dependency and Unblock Example - -```text -#1 Design schema - ├──→ #2 Implement API ──→ #3 Unit tests - └──→ #4 Write docs - -#1 completed → unblocked: ['2', '4'] -#2 completed → unblocked: ['3'] -``` - -### Task Tool Family Best Practices - -- **Separate planning from execution**: `task_create` + `addBlockedBy` first, then `in_progress` → `completed` item by item -- **Do not invent ids**: use only ids returned by `task_create` -- **Parallel calls**: with `parallel_tool_calls=True`, concurrent `task_create` / `task_update` on the same board are serialized by the lock; different `branch` values still run in parallel -- **Pick TodoWrite or Task**: long boards + dependencies → Task; short checklists → TodoWrite - -### Task Tool Family Complete Examples - -| Example | Description | -| --- | --- | -| [examples/task_tools](../../../examples/task_tools/) | Multi-turn dialog: dependency graph, step-by-step completion, `get_task_store` across turns | -| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | Validates `parallel_tool_calls` + `task_store_lock` (Phase 1–2 needs no API key) | - ---- - -## Goal Tool Family (Persistent Session Goal) - -`GoalToolSet` exposes three tools — `create_goal`, `get_goal`, `update_goal` — aligned with Claude Code **Session Goal** capabilities. Unlike `TodoWriteTool` (multi-item checklist) and `TaskToolSet` (multi-task board), Goal maintains **at most one** persistent objective per session branch: while status is `active`, a response that **looks like a final answer** does **not** mean the work is done — the model must keep working or explicitly call `update_goal('complete' | 'blocked')`. - -The goal is serialized as a **single JSON blob** (`GoalRecord`) in `tool_context.state["goal[:]"]`, surviving across `Runner.run_async` calls. Beyond the three model tools, the full capability requires `setup_goal()` to mount **enforcement callbacks** (`before_model` / `after_model`) that intercept premature final responses and re-run within the **same invocation**. - -### Features - -- **Single-goal contract**: one `GoalRecord` per branch (`objective` + three states `active` / `complete` / `blocked`); `complete` / `blocked` are **irreversible** terminal states -- **Cross-turn persistence**: persisted via function-response state deltas; **do not** use the `temp:` prefix -- **Sub-agent isolation**: state key appends `:` -- **Enforced completion**: while `active`, `after_model` detects premature finals (no tool call, visible text, non-partial), suppresses them, and re-runs in the same invocation; `before_model` injects a user-role nudge -- **Fail-open budget**: after `max_retries` (default 3) interceptions, the final response is allowed so the loop cannot spin forever; counters live in invocation-scoped `agent_context.metadata` and are not persisted -- **Two creation paths**: - - **Model side**: `create_goal(objective=...)` — LLM creates after judging a multi-step task - - **Host side**: `start_goal(session_service, ...)` — application writes the goal before the first turn; the model does not call `create_goal` -- **Layered prompt guidance**: `DEFAULT_GUIDANCE` injected into system instruction via `before_model` when `inject_guidance=True`; hard rules enforced by store validation + callbacks -- **Concurrency safety**: `_GoalToolBase` wraps load → mutate → save in `goal_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` - -### Relationship to Todo / Task - -| Dimension | `TodoWriteTool` | `TaskToolSet` | Goal Tool Family | -| --- | --- | --- | --- | -| Granularity | Multi-item checklist | Multi-task board + deps | **Single** session objective | -| Update style | Full-list replace | Incremental by `taskId` | `create_goal` / `update_goal` | -| Can finish while incomplete? | Prompt guidance | Prompt guidance | **Callback enforcement** | -| State key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | -| Typical use | Step visibility, short lists | Long boards, dependencies | Whether the whole job is truly done | - -> Todo / Task handle **step decomposition**; Goal handles the **overall completion contract**. They can be combined, but avoid mounting too many planning tools at once. - -### GoalOptions Constructor Parameters - -Configure via `setup_goal(agent, GoalOptions(...))`: - -| Parameter | Type | Default | Description | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"goal"` | State key prefix; do not use `temp:` | -| `inject_guidance` | `bool` | `True` | Inject `DEFAULT_GUIDANCE` into system instruction in `before_model` | -| `guidance` | `str` | `DEFAULT_GUIDANCE` | Long guidance text (serial goal-tool calls, etc.) | -| `max_retries` | `int` | `3` | Same-invocation budget for intercepting premature finals; fail-open when exhausted | -| `nudge_template` | `str` | `DEFAULT_NUDGE` | User-role reminder after interception; supports `{attempt}` / `{max_retries}` / `{objective}` | -| `on_retry` | `Callable[[RetryEvent], None]` | `None` | Observability callback on each interception or budget exhaustion | - -Mounting only `GoalToolSet()` without enforcement gives model-facing tools but **not** "no final while active". - -### LLM Parameters for the Three Tools - -**`create_goal`** - -| Parameter | Required | Description | -|------|------|------| -| `objective` | Yes | Completion criteria — what "done" concretely means | - -Success: `{message, goal}`; if an `active` goal already exists: `{error: "INVALID_STATE: ..."}`. - -**`get_goal`** - -No parameters. With a goal: `{message, goal}`; without: `{message: "No session goal is set."}`. - -**`update_goal`** - -| Parameter | Required | Description | -|------|------|------| -| `status` | Yes | `complete` (objective met) or `blocked` (same blocker repeats; cannot proceed without user input) | - -Success: `{message, goal}`; no active goal or already terminal: `{error: "INVALID_STATE: ..."}`. - -**`GoalRecord` fields** (persisted with camelCase JSON aliases): - -| Field | Description | -|------|------| -| `id` | Server-assigned uuid | -| `objective` | Completion criteria text | -| `status` | `active` / `complete` / `blocked` | -| `createdAtUnix` / `updatedAtUnix` | Created / last-updated time (unix seconds) | -| `terminalAtUnix` | Time entered a terminal state (optional) | - -### Enforcement Workflow - -```text -Model outputs final text (no tool call, goal still active) - ↓ -after_model classifies as premature final - ↓ -Suppress final (not committed as answer), retry_count += 1 -before_model injects nudge, same invocation continues agent loop - ↓ -retry_count >= max_retries → fail-open, on_retry(reason="exhausted") -``` - -Interception condition (`_is_premature_final`): non-partial, no error, visible text in content, and **no** `function_call` / `function_response`. - -### Usage - -Recommended one-line mount of tools + callbacks: - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal - -def on_retry(event: RetryEvent) -> None: - if event.reason == "blocked": - print(f"Premature final intercepted (attempt {event.attempt_number}/{event.max_retries})") - -agent = LlmAgent( - name="goal_agent", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="Use goal tools to track completion for multi-step engineering tasks.", - tools=[...], # Business tools, e.g. BashTool / WriteTool -) -setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) -``` - -Host pre-injects a goal before the first turn (model does not call `create_goal`): - -```python -from trpc_agent_sdk.tools.goal_tools import start_goal - -goal = await start_goal( - session_service, - app_name="my_app", - user_id="user_1", - session_id=session_id, - objective="Create notes/ with summary.txt and example.py in the current directory", - agent_name=agent.name, # Match LlmAgent.name for branch isolation -) -``` - -Read back the persisted goal (REST / audit / demo wrap-up): - -```python -from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal - -goal = get_goal_record(session, branch=agent.name) -print(render_goal(goal)) -# ✅ Goal [complete] -# objective: ... -# created: 1782893110 -# terminal: 1782893116 -``` - -### Goal Tool Family Best Practices - -- **Use `setup_goal`, not `GoalToolSet` alone**: only callbacks enforce "no final while active" -- **Model vs host**: slash commands, `/goal`, config-driven tasks → `start_goal()`; let the model judge multi-step work → `create_goal` -- **One goal tool per response**: `DEFAULT_GUIDANCE` requires serial semantics; do not call `create_goal` and `update_goal` in the same turn -- **Use `blocked` sparingly**: only when the same blocker repeats across attempts and user input or external state change is required; do not mark blocked because work is hard, slow, or incomplete -- **Observability**: use `on_retry` to log premature-final interceptions and budget exhaustion when tuning prompt or `max_retries` -- **Division of labor with Todo / Task**: Todo / Task show steps and dependencies; Goal constrains whether the whole job is truly finished - -### Goal Tool Family Complete Example - -| Example | Description | -| --- | --- | -| [examples/goal_tools](../../../examples/goal_tools/) | Case 1: model `create_goal`; Case 2: host `start_goal` pre-injection; demonstrates enforcement interception and `update_goal(complete)` | diff --git a/docs/mkdocs/en/tool_task.md b/docs/mkdocs/en/tool_task.md new file mode 100644 index 00000000..4e0f8cf4 --- /dev/null +++ b/docs/mkdocs/en/tool_task.md @@ -0,0 +1,108 @@ +## Task Tool Family (Structured Task Board) + +`TaskToolSet` exposes four tools — `task_create`, `task_update`, `task_get`, `task_list` — aligned with Claude Code v2.1.142+ structured Task capabilities. Unlike `TodoWriteTool`'s full-list replace, the Task family uses **incremental updates by server-assigned `id`**: creation returns an id; later `task_update` patches status, fields, or dependency edges locally. + +The entire board is serialized as a **single JSON blob** in `tool_context.state["tasks[:]"]`, surviving across turns. `highwatermark` records the highest id ever assigned; soft-deleted tasks (`status: deleted`) **never reuse ids**. + +### Features + +- **Incremental updates**: `task_create` assigns ids; `task_update` patches by `taskId` without resending the whole board +- **Dependency orchestration**: `addBlockedBy` / `removeBlockedBy` (and `addBlocks` / `removeBlocks`) maintain bidirectional edges; upstream `completed` removes ids from downstream `blockedBy` and returns `unblocked` +- **Token optimization**: `task_list` returns summaries only (omits `description`); use `task_get` for full detail +- **Hard contract validation**: non-empty `subject`, valid status, existing dependency refs, **acyclic** graph (`detect_cycle`), default **at most one `in_progress`** (`enforce_single_in_progress`, can disable) +- **Concurrency safety**: `_TaskToolBase` wraps load → mutate → save in `task_store_lock` (per session + branch), compatible with `parallel_tool_calls=True` +- **Auto prompt injection**: `DEFAULT_TASK_PROMPT` injected once when multiple tools are mounted + +### TaskToolSet Constructor Parameters + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"tasks"` | State key prefix; do not use `temp:` | +| `enforce_single_in_progress` | `bool` | `True` | Reject a second `in_progress` when one already exists | +| `inject_prompt` | `bool` | `True` | Inject `DEFAULT_TASK_PROMPT` into system instruction | + +### LLM Parameters for the Four Tools + +**`task_create`** + +| Parameter | Required | Description | +|------|------|------| +| `subject` | Yes | Short imperative title | +| `description` | No | Free-text detail | +| `activeForm` | No | In-progress label | +| `metadata` | No | Extension key-value map | + +Returns `{task: {id, subject}, message}`. + +**`task_update`** + +| Parameter | Required | Description | +|------|------|------| +| `taskId` | Yes | Task id to update | +| `status` | No | `pending` / `in_progress` / `completed` / `deleted` | +| `subject` / `description` / `activeForm` / `owner` / `metadata` | No | Scalar field patches | +| `addBlockedBy` / `removeBlockedBy` | No | Upstream dependency id lists | +| `addBlocks` / `removeBlocks` | No | Downstream blocked-id lists | + +Returns `{task, unblocked, message}`; `unblocked` lists pending task ids unblocked by this completion. + +**`task_get`**: `taskId` (required) → full record including `description`. + +**`task_list`**: optional `includeDeleted`; returns `{tasks, stats}` with summaries (no `description`). + +**Common error codes**: `INVALID_ARGS`, `INVALID_DEPENDENCY`, `INVALID_STATUS`, `NOT_FOUND`. + +### Usage + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TaskToolSet + +agent = LlmAgent( + name="task_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="Use task_create / task_update to maintain the board for multi-step projects.", + tools=[TaskToolSet()], + # With parallel_tool_calls=True, concurrent task tools on the same board are serialized by task_store_lock +) +``` + +Read back the persisted board (REST / audit / demo wrap-up): + +```python +from trpc_agent_sdk.tools import get_task_store, render_task_list + +store = get_task_store(session, branch=agent.name) +print(render_task_list(store)) +# ✅ #1 completed +# 🔄 #2 in progress +# ⬜ #3 pending (blocked by: 2) +``` + +### Dependency and Unblock Example + +```text +#1 Design schema + ├──→ #2 Implement API ──→ #3 Unit tests + └──→ #4 Write docs + +#1 completed → unblocked: ['2', '4'] +#2 completed → unblocked: ['3'] +``` + +### Task Tool Family Best Practices + +- **Separate planning from execution**: `task_create` + `addBlockedBy` first, then `in_progress` → `completed` item by item +- **Do not invent ids**: use only ids returned by `task_create` +- **Parallel calls**: with `parallel_tool_calls=True`, concurrent `task_create` / `task_update` on the same board are serialized by the lock; different `branch` values still run in parallel +- **Pick TodoWrite or Task**: long boards + dependencies → Task; short checklists → TodoWrite + +### Task Tool Family Complete Examples + +| Example | Description | +| --- | --- | +| [examples/task_tools](../../../examples/task_tools/) | Multi-turn dialog: dependency graph, step-by-step completion, `get_task_store` across turns | +| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | Validates `parallel_tool_calls` + `task_store_lock` (Phase 1–2 needs no API key) | + +--- diff --git a/docs/mkdocs/en/tool_todowrite.md b/docs/mkdocs/en/tool_todowrite.md new file mode 100644 index 00000000..6d253094 --- /dev/null +++ b/docs/mkdocs/en/tool_todowrite.md @@ -0,0 +1,83 @@ +## TodoWriteTool (Task Checklist Tool) + +`TodoWriteTool` is the framework's built-in **structured task checklist tool**, aligned with Claude Code / DeepAgents `TodoWrite` semantics: the model sends the **complete, updated list** in a single `todo_write` call; the tool validates it, fully replaces the previous list, and persists it to session-level state so plans and progress survive across `Runner.run_async` invocations. + +Best for **fewer steps, no explicit dependency edges, and simple implementation**. If you need server-assigned ids, incremental `taskId` patches, or `blockedBy` / `blocks` dependency orchestration, use the [Task Tool Family](./tool_task.md) instead. + +### Features + +- **Full-list replace**: each call passes the complete `todos` array; the new list **fully overwrites** the old one (no smart merge). The only valid way to clear is an explicit `todos: []` +- **Session-level persistence**: the checklist is serialized to JSON in `tool_context.state["todos[:]"]` (default prefix `todos`; **do not** use `temp:` — that prefix is stripped by `BaseSessionService` and is not persisted) +- **Sub-agent isolation**: the state key appends `:` so parent / child agents maintain separate lists +- **Hard contract validation (code-enforced)**: non-empty `content` / `activeForm`, at most one `in_progress`, globally unique `content`; violations return `INVALID_ARGS` / `INVALID_TODOS` +- **Layered prompt guidance**: `DEFAULT_TODO_PROMPT` is auto-injected into the system instruction via `process_request`, separate from hard contracts +- **Structured diff in responses**: on success returns `{message, todos, oldTodos}` for front-end / CLI rendering +- **Optional policy hooks**: read-only `nudge_hooks` can append strategy hints to `message` (must not modify the list) +- **Auto-clear when all done**: with `clear_on_all_done=True` (default), an all-`completed` list is persisted as empty to avoid stale accumulation + +### TodoWriteTool Parameters + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"todos"` | State key prefix; do not use `temp:` | +| `clear_on_all_done` | `bool` | `True` | Clear persisted list when all items are `completed` | +| `default_nudge` | `str` | built-in text | Base hint appended on every successful response | +| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | Read-only policy hook list | +| `filters_name` / `filters` | — | `None` | Filters forwarded to `BaseTool` | + +**LLM call parameters** (`todo_write`): + +| Parameter | Type | Required | Description | +|------|------|------|------| +| `todos` | `array` | Yes | Full list; each item has `content` (imperative), `activeForm` (in-progress label), `status` (`pending` / `in_progress` / `completed`) | + +**Successful response fields**: + +| Field | Type | Description | +|------|------|------| +| `message` | `str` | Base nudge + hook-appended text | +| `todos` | `array` | Persisted current list | +| `oldTodos` | `array \| null` | Previous list (`null` on first write) | + +### Usage + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TodoWriteTool + +agent = LlmAgent( + name="todo_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="You are a planning assistant; use todo_write for multi-step tasks.", + tools=[TodoWriteTool()], +) +``` + +Read back the persisted checklist (REST / audit): + +```python +from trpc_agent_sdk.tools import get_todos, render_todos + +todos = get_todos(session, branch=agent.name) +print(render_todos(todos)) # ✅ / 🔄 / ⬜ plain-text checklist +``` + +### TodoWriteTool vs Task Tool Family + +| Dimension | `TodoWriteTool` | `TaskToolSet` | +| --- | --- | --- | +| Tool count | 1 (`todo_write`) | 4 (`task_create` / `task_update` / `task_get` / `task_list`) | +| Update style | Full-list replace | Incremental patch by `taskId` | +| Item identity | `content` (unique key) | `id` (server-assigned) | +| Dependencies | None | `blockedBy` / `blocks`; upstream `completed` auto-unblocks | +| State key | `todos[:branch]` | `tasks[:branch]` | +| Parallel tool calls | Full-list overwrite, natural last-write-wins | `task_store_lock` serializes RMW | + +> **Mount one or the other**; mounting both tends to confuse the model. + +### TodoWriteTool Complete Example + +See [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py): multiple turns in one session — plan → complete items step by step — with `get_todos` reading back the persisted list after each turn. + +--- diff --git a/docs/mkdocs/zh/goal.md b/docs/mkdocs/zh/goal.md new file mode 100644 index 00000000..742114b0 --- /dev/null +++ b/docs/mkdocs/zh/goal.md @@ -0,0 +1,157 @@ +## Goal 工具族(持久会话目标) + +`GoalToolSet` 暴露三个工具——`create_goal`、`get_goal`、`update_goal`——对齐 Claude Code 的 **Session Goal** 能力。与 `TodoWriteTool`(多行待办)和 `TaskToolSet`(多任务看板)不同,Goal 在每个 session branch 上**至多只有一个**持久目标:在目标为 `active` 期间,模型给出「看起来像最终答案」的文本**不算完成**——必须继续执行,或显式调用 `update_goal('complete' | 'blocked')` 收尾。 + +目标序列化为**单个 JSON blob**(`GoalRecord`)写入 `tool_context.state["goal[:]"]`,跨 `Runner.run_async` 调用存活。除三个模型工具外,完整能力还需通过 `setup_goal()` 挂载一对 **enforcement callbacks**(`before_model` / `after_model`),在**同一次 invocation 内**拦截过早的最终回复并自动重试。 + +### 功能特性 + +- **单目标契约**:每个 branch 一个 `GoalRecord`(`objective` + 三态 `active` / `complete` / `blocked`);`complete` / `blocked` 为**不可逆**终态 +- **跨轮持久化**:随 function-response 的 state delta 落库;**勿用** `temp:` 前缀 +- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立目标 +- **强制收尾(enforcement)**:目标 `active` 时,`after_model` 检测「无 tool call、有可见文本、非 partial」的过早 final,抑制该回复并在同 invocation 内 re-run;`before_model` 注入 user-role nudge +- **fail-open 预算**:`max_retries`(默认 3)次拦截后放行最终回复,避免无限循环;计数器存在 invocation 级 `agent_context.metadata`,不持久化 +- **双入口创建**: + - **模型侧**:`create_goal(objective=...)` —— LLM 判断多步任务后自主创建 + - **宿主侧**:`start_goal(session_service, ...)` —— 应用层在首轮前写入 session,模型无需调用 `create_goal` +- **Prompt 引导分层**:`DEFAULT_GUIDANCE` 在目标 active 时经 `before_model` 注入 system instruction(`inject_guidance=True`);硬约束由 store 校验 + callback 共同保证 +- **并发安全**:`_GoalToolBase` 在 load → mutate → save 外包 `goal_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` + +### 与 Todo / Task 的关系 + +| 维度 | `TodoWriteTool` | `TaskToolSet` | Goal 工具族 | +| --- | --- | --- | --- | +| 粒度 | 多行待办清单 | 多任务看板 + 依赖 | **单个**会话目标 | +| 更新方式 | 整表替换 | 按 `taskId` 增量 | `create_goal` / `update_goal` | +| 未完成能否收尾 | Prompt 引导 | Prompt 引导 | **callback 强制拦截** | +| state key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | +| 典型用途 | 步骤可视、短清单 | 长看板、依赖编排 | 整件事是否算做完 | + +> Todo / Task 管「步骤分解」,Goal 管「整体完成契约」。可组合使用,但避免让模型同时混用过多规划工具。 + +### GoalOptions 构造参数 + +通过 `setup_goal(agent, GoalOptions(...))` 配置: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"goal"` | state key 前缀;勿使用 `temp:` | +| `inject_guidance` | `bool` | `True` | 是否在 `before_model` 向 system instruction 注入 `DEFAULT_GUIDANCE` | +| `guidance` | `str` | `DEFAULT_GUIDANCE` | 注入的长文案(含串行调用 goal 工具等约定) | +| `max_retries` | `int` | `3` | 同 invocation 内拦截过早 final 的预算;耗尽后 fail-open | +| `nudge_template` | `str` | `DEFAULT_NUDGE` | 拦截后以 user-role 追加的提醒模板(支持 `{attempt}` / `{max_retries}` / `{objective}`) | +| `on_retry` | `Callable[[RetryEvent], None]` | `None` | 每次拦截或预算耗尽时的可观测回调 | + +仅挂载模型工具、不要 enforcement 时,可直接 `tools=[GoalToolSet()]`,但不具备「未完成不许收尾」能力。 + +### 三个工具的 LLM 参数概要 + +**`create_goal`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `objective` | 是 | 完成标准——「done」具体指什么 | + +成功返回 `{message, goal}`;若已有 `active` 目标返回 `{error: "INVALID_STATE: ..."}`。 + +**`get_goal`** + +无参数。有目标时返回 `{message, goal}`;无目标时返回 `{message: "No session goal is set."}`。 + +**`update_goal`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `status` | 是 | `complete`(目标已达成)或 `blocked`(同一阻塞条件反复出现、无用户输入无法继续) | + +成功返回 `{message, goal}`;无 active 目标或已是终态时返回 `{error: "INVALID_STATE: ..."}`。 + +**`GoalRecord` 字段**(JSON 使用 camelCase 别名持久化): + +| 字段 | 说明 | +|------|------| +| `id` | 服务端分配的 uuid | +| `objective` | 完成标准文本 | +| `status` | `active` / `complete` / `blocked` | +| `createdAtUnix` / `updatedAtUnix` | 创建 / 最后更新时间(unix 秒) | +| `terminalAtUnix` | 进入终态的时间(可选) | + +### enforcement 工作流程 + +```text +模型输出 final 文本(无 tool call,goal 仍 active) + ↓ +after_model 判定为 premature final + ↓ +抑制该 final(不提交为答案),retry_count += 1 +before_model 注入 nudge,同 invocation 继续 agent loop + ↓ +retry_count >= max_retries → fail-open,on_retry(reason="exhausted") +``` + +拦截条件(`_is_premature_final`):非 partial、无 error、content 含可见文本,且**不含** `function_call` / `function_response`。 + +### 使用方式 + +推荐一行挂载工具 + callbacks: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal + +def on_retry(event: RetryEvent) -> None: + if event.reason == "blocked": + print(f"拦截过早收尾 (attempt {event.attempt_number}/{event.max_retries})") + +agent = LlmAgent( + name="goal_agent", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="多步工程任务请用 goal 工具跟踪完成状态。", + tools=[...], # 业务工具,如 BashTool / WriteTool +) +setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) +``` + +宿主在首轮前预注入目标(模型不调用 `create_goal`): + +```python +from trpc_agent_sdk.tools.goal_tools import start_goal + +goal = await start_goal( + session_service, + app_name="my_app", + user_id="user_1", + session_id=session_id, + objective="在当前目录创建 notes/ 并写入 summary.txt 与 example.py", + agent_name=agent.name, # 与 LlmAgent.name 一致,用于 branch 隔离 +) +``` + +读回持久化目标(REST / 审计 / demo 收尾): + +```python +from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal + +goal = get_goal_record(session, branch=agent.name) +print(render_goal(goal)) +# ✅ Goal [complete] +# objective: ... +# created: 1782893110 +# terminal: 1782893116 +``` + +### Goal 工具族最佳实践 + +- **用 `setup_goal` 而非只挂 `GoalToolSet`**:只有 callbacks 才能实现「active 期间不许 final」 +- **模型侧 vs 宿主侧**:Slash command、`/goal`、配置驱动任务用 `start_goal()`;让模型自主判断多步任务时用 `create_goal` +- **一次响应只调一个 goal 工具**:`DEFAULT_GUIDANCE` 要求串行语义;不要同轮 `create_goal` + `update_goal` +- **`blocked` 慎用**:仅当同一阻塞条件跨多次尝试仍无法推进、且需要用户输入或外部状态变化时使用;不要因为任务难、慢或不完整就标记 blocked +- **可观测性**:通过 `on_retry` 记录 `⚡ Premature final intercepted` 与预算耗尽,便于调优 prompt 或 `max_retries` +- **与 Todo / Task 分工**:Todo / Task 展示步骤与依赖;Goal 约束「整件事是否已真正完成」 + +### Goal 工具族完整示例 + +| 示例 | 说明 | +| --- | --- | +| [examples/goal_tools](../../../examples/goal_tools/) | Case 1:模型 `create_goal`;Case 2:宿主 `start_goal` 预注入;演示 enforcement 拦截与 `update_goal(complete)` | diff --git a/docs/mkdocs/zh/index.md b/docs/mkdocs/zh/index.md index fb2feccc..270e6f11 100644 --- a/docs/mkdocs/zh/index.md +++ b/docs/mkdocs/zh/index.md @@ -16,6 +16,7 @@ - [Memory](./memory.md):存储和检索长期记忆。 - [Knowledge](./knowledge.md):基于 LangChain 组件构建 RAG 工作流。 - [多 Agent](./multi_agents.md):编排多个 Agent 完成复杂任务。 +- [Plan Mode](./plan.md):先规划后实施,写工具 gate + HITL 审批。 - [Evaluation](./evaluation.md):评测 Agent 行为和回复质量。 源码与示例请参考 [GitHub 仓库](https://github.com/trpc-group/trpc-agent-python)。 diff --git a/docs/mkdocs/zh/plan.md b/docs/mkdocs/zh/plan.md new file mode 100644 index 00000000..349ad32d --- /dev/null +++ b/docs/mkdocs/zh/plan.md @@ -0,0 +1,343 @@ +# Plan Mode(计划模式) + +Plan Mode 为 `LlmAgent` 提供 **先规划、后实施** 的工作流:规划阶段模型只能使用只读工具并撰写计划文档;所有具备副作用的写工具,在人工通过 HITL 审批计划之前会被代码级 gate 拦截。 + +常与 `SpawnSubAgentTool`(`EXPLORE_AGENT` / `PLAN_AGENT`)配合做代码调研与方案设计,批准后可搭配 `TodoWriteTool` 或 `TaskToolSet` 跟踪实施进度。 + +## 解决什么问题 + +普通 coding agent 容易一上来就改文件、边写边改方向。Plan Mode 把流程拆成两段: + +1. **规划阶段** —— 用只读工具(`Read` / `Grep` / `Glob`)、只读子 agent(`Explore` / `Plan`)调研代码,通过 `update_plan_content` 写计划、用 `ask_user_question` 澄清需求;`Write` / `Edit` / `Bash`、`todo_write`、`task_create` 等副作用工具被 gate。 +2. **实施阶段** —— 人工批准后写工具解锁,模型按批准的计划落地实现。 + +三个需要人工介入的节点——`enter_plan_mode`、`exit_plan_mode`、`ask_user_question`——均为 `LongRunningFunctionTool`,执行会暂停,直到宿主以 tool function response 续跑。通用 HITL 机制见 [Human in the Loop](./human_in_the_loop.md)。 + +## 为什么这很重要 + +AI 编码 Agent 最大的风险之一不是写错代码,而是**写对了错误的东西**。当用户说「重构认证模块」时,Agent 可能选择 JWT 方案,而用户心中想的是 OAuth2。如果 Agent 直接开始实现,等用户发现方向错误时,已经修改了十几个文件。 + +Plan Mode 解决的是**意图对齐**问题:在 Agent 动手修改代码之前,先让它探索代码库、制定计划、获得用户审批。这不是简单的「先问再做」——它是一套完整的状态机,涉及: + +- 写工具 gate(权限级行为约束) +- 计划文档持久化(对齐载体) +- 工作流提示词注入 +- 进入 / 退出两处 HITL 审批 +- 与 UI Plan 开关(`agent_mode=plan`)的联动 + +## 解决方案:四步闭环 + +Plan Mode 在对话中引入**只读阶段**,通过 `enter_plan_mode` 与 `exit_plan_mode` 两个长运行工具形成闭环: + +| 步骤 | 名称 | 行为 | +| --- | --- | --- | +| 1 | **进入计划模式** | 模型判断任务需要规划,或用户在 UI 选择 Plan Mode 后,调用 `enter_plan_mode`(HITL:需用户确认进入) | +| 2 | **探索阶段** | 权限约束为只读:仅允许 `Read` / `Grep` / `Glob`、只读子 agent(`Explore` / `Plan`)及 `update_plan_content`;所有写操作在 `before_tool` 被拦截 | +| 3 | **提交方案审批** | 探索完成后调用 `exit_plan_mode`,将计划文档提交给用户审阅(HITL:需用户批准或拒绝) | +| 4 | **恢复执行** | 用户批准后状态变为 `approved`,写工具 gate 关闭,Agent 按批准计划以完整工具权限实施 | + +```mermaid +flowchart LR + A["① enter_plan_mode
进入计划模式"] --> B["② 探索阶段
只读工具集"] + B --> C["update_plan_content
撰写计划"] + C --> D["③ exit_plan_mode
提交审批"] + D --> E{用户批准?} + E -->|拒绝| C + E -->|批准| F["④ approved
恢复写权限"] + F --> G["按批准计划实施"] +``` + +```text +用户 / 模型 HITL 只读 gate HITL 全权限 + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ +enter_plan_mode ──批准──▶ exploring/drafting ──写计划──▶ exit_plan_mode ──批准──▶ approved → Write/Edit/... +``` + +## 关键设计决策 + +从工程角度看,Plan Mode 体现三个核心设计决策(与 Claude Code Plan Mode 理念对齐,在 tRPC-Agent-Python 中的落地方式如下): + +### 1. 权限模式作为行为约束 + +进入 Plan Mode 后,模型的工具集被限制为只读——**不是**靠提示词「请不要修改文件」,而是通过 `before_tool` 在工具调用前拦截写入操作(`PLAN_MODE_GATE` 错误)。子 agent 同样受约束:`spawn_subagent` 仅允许 `Explore` / `Plan` 原型,避免继承父 agent 的写工具面。 + +### 2. 计划文档作为对齐载体 + +计划不是停留在对话里的零散文字,而是持久化的 **Markdown 制品**(`PlanRecord.content`),写入 session state(`plan[:branch]`),跨轮 `Runner.run_async` 存活。用户可在审批环节编辑计划(`exit_plan_mode` 续跑时传入 `content`);AG-UI 通过 `STATE_SNAPSHOT` 实时展示计划面板,便于远程会话与本地 UI 对齐。 + +### 3. 状态机而非布尔开关 + +Plan Mode 不是简单的 `isPlanMode` 标志,而是包含进入、探索、起草、审批、批准、恢复的完整状态转换链(`PlanStatus`),每个转换都有副作用: + +| 转换 | 副作用 | +| --- | --- | +| `enter_plan_mode` 批准 | 创建 `PlanRecord`,进入 `exploring`,开启写 gate | +| `update_plan_content` | `exploring` → `drafting`,追加/替换计划正文 | +| `exit_plan_mode` | → `pending_approval`,暂停等待审批 | +| 批准 | → `approved`,关闭写 gate,释放 plan 锁 | +| 拒绝 | → `drafting`,允许修订后再次提交 | + +## 架构 + +``` +orchestrator(LlmAgent + setup_plan) +├── 业务工具(如 FileToolSet、SpawnSubAgentTool、TodoWriteTool) +└── PlanToolSet(由 setup_plan 挂载) + ├── enter_plan_mode (LongRunningFunctionTool — HITL) + ├── update_plan_content + ├── exit_plan_mode (LongRunningFunctionTool — HITL) + └── ask_user_question (LongRunningFunctionTool — HITL) + +_PlanCallbacks(before_model / before_tool) +├── 注入 plan / awareness 提示词 +├── 处理 HITL 续跑 payload +├── 根据 session state 信号自动进入 Plan Mode +└── gate 激活期间拦截写工具 +``` + +- 计划文档持久化在**主 agent 的 session** 中,key 为 `state["plan[:]"]`(默认前缀 `plan`)。 +- 被 spawn 的子 agent 只返回文本,不直接修改父 agent 的 plan 状态。 + +## 状态机 + +| 状态 | 含义 | 写 gate | +| --- | --- | --- | +| `pending_enter` | 等待人工确认进入 Plan Mode | 开启 | +| `exploring` | 只读探索中 | 开启 | +| `drafting` | 正在撰写计划 | 开启 | +| `pending_approval` | 计划已提交,等待审批 | 开启 | +| `approved` | 人工批准,可开始实施 | **关闭** | + +典型流转: + +```text +enter_plan_mode(HITL)→ exploring → update_plan_content → drafting + → exit_plan_mode(HITL)→ pending_approval + → approved → 使用写工具实施 +``` + +`exit_plan_mode` 被拒绝时,状态回到 `drafting`,可修订后再次提交。 + +## 功能特性 + +- **会话级计划制品** —— `PlanRecord` 序列化为 JSON 写入 session state,跨 `Runner.run_async` 调用存活 +- **写工具 gate** —— gate 激活时 `before_tool` 拦截 `DEFAULT_WRITE_TOOL_NAMES` 中的工具 +- **子 agent 限制** —— `spawn_subagent` 仅允许只读原型(`Explore`、`Plan`);`dynamic_subagent` 须显式将 `tools` 限制为只读子集 +- **Prompt 注入** —— 无活跃计划时注入 awareness 提示;gate 激活时注入完整 Plan Mode 提示 +- **HITL 工具** —— 三个长运行工具暂停等待人工;续跑在 `before_model` 中处理 +- **UI 自动进入** —— session state 为 `agent_mode=plan` 时自动进入,并从工具 schema 中隐藏 `enter_plan_mode` +- **HITL 幂等** —— 仅处理最新 user turn 的 function response;历史中的旧 rejection 不会回放 +- **并发安全** —— plan 工具在 load → mutate → save 外包 `plan_store_lock`(按 session + branch) + +## 与 Todo / Task / Goal 的关系 + +| 维度 | TodoWriteTool | TaskToolSet | Goal | **Plan Mode** | +| --- | --- | --- | --- | --- | +| 用途 | 步骤清单 | 任务看板 + 依赖 | 会话完成契约 | **设计文档 + 写操作审批** | +| 人工审批 | 无 | 无 | 无(仅 enforcement) | **有(进入 + 退出 HITL)** | +| 拦截写工具 | 无(仅 prompt) | 无 | 无 | **有(代码强制 gate)** | +| state key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | `plan[:branch]` | +| 典型场景 | 批准后跟踪步骤 | 长看板、依赖编排 | 整件事是否做完 | **调研 → 起草 → 批准 → 实施** | + +> Todo / Task 管步骤分解,Goal 管完成契约;**Plan Mode 在人工签字前拦截所有副作用操作。** + +## PlanOptions 构造参数 + +通过 `setup_plan(agent, PlanOptions(...))` 配置: + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `state_key_prefix` | `str` | `"plan"` | state key 前缀;勿使用 `temp:` | +| `plan_prompt` | `str` | `DEFAULT_PLAN_MODE_PROMPT` | gate 激活时注入 | +| `awareness_prompt` | `str` | `DEFAULT_PLAN_AWARENESS_PROMPT` | 无活跃计划时注入 | +| `write_tool_names` | `FrozenSet[str]` | `DEFAULT_WRITE_TOOL_NAMES` | gate 期间拦截的工具名;MCP / 自定义写工具需扩展 | +| `inject_prompt` | `bool` | `True` | gate 激活时是否注入 plan prompt | +| `inject_awareness` | `bool` | `True` | 否则是否注入 awareness prompt | +| `force_enter_plan_state_key` | `Optional[str]` | `"agent_mode"` | UI 自动进入的 session key;`None` 禁用 | +| `force_enter_plan_state_value` | `str` | `"plan"` | 触发自动进入的值 | +| `on_approval` | `Callable` | `None` | `exit_plan_mode` 批准 / 拒绝时的回调 | +| `readonly_subagent_types` | `FrozenSet[str]` | `{"Explore", "Plan"}` | gate 期间允许的 `spawn_subagent` 原型 | +| `readonly_tool_names` | `FrozenSet[str]` | `{"Read", "Grep", "Glob", "webfetch", "websearch"}` | `dynamic_subagent` 允许的 `tools` 子集 | + +> 每个 agent **只调用一次** `setup_plan()`,重复调用会重复挂载 toolset 与 callback。 + +## 工具说明 + +### `enter_plan_mode`(LongRunningFunctionTool) + +请求人工确认后进入 Plan Mode。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `objective` | 是 | 计划目标的简短描述 | + +返回 `{status: "pending_enter", message, objective, plan_id, approval_id, ...}`。续跑格式:`{status: "approved"}` 或 `{status: "rejected", reviewer_note?: "..."}`。 + +当 session 已设 `agent_mode=plan` 或 gate 已激活时,该工具会从 schema 中隐藏。 + +### `update_plan_content` + +追加或替换 Markdown 计划正文。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `content` | 是 | 计划 Markdown | +| `mode` | 否 | `"append"`(默认)或 `"replace"` | + +首次写入会将状态从 `exploring` 推进到 `drafting`。 + +### `exit_plan_mode`(LongRunningFunctionTool) + +提交计划供人工审批。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `summary` | 否 | 给审批人的简短摘要 | + +要求计划正文非空。返回 `{status: "pending_approval", content, preview, ...}`。续跑格式: + +```json +{"status": "approved"} +``` + +或 + +```json +{"status": "rejected", "reviewer_note": "请补充错误处理章节"} +``` + +批准时可选传入 `content` 以应用审批人编辑后的计划。 + +### `ask_user_question`(LongRunningFunctionTool) + +规划期间的定向澄清。 + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `question` | 是 | 问题文本 | +| `options` | 否 | 建议选项列表 | + +续跑格式:`{status: "answered", question_id: , answer: "<文本>"}`。 + +## 写 Gate 规则 + +当 `PlanRecord.is_gate_active()`(`exploring` / `drafting` / `pending_approval`)时: + +| 工具类型 | 规则 | +| --- | --- | +| Plan 工具(`enter_plan_mode`、`update_plan_content`、`exit_plan_mode`、`ask_user_question`) | 始终允许(含状态校验) | +| `spawn_subagent` | 仅 `Explore`、`Plan` 原型 | +| `dynamic_subagent` | 仅当 `tools` 为 `readonly_tool_names` 的显式子集 | +| `write_tool_names` 中的工具 | 返回 `PLAN_MODE_GATE` 错误 | + +默认拦截:`Write`、`Edit`、`Bash`、`todo_write`、`task_create`、`task_update`、`create_goal`、`update_goal`。 + +## HITL 续跑(宿主集成) + +续跑时提交 user 消息,parts 中包含暂停工具的 `function_response`。`before_model` 调用 `process_hitl_function_response`,将宿主原始 payload 替换为状态机的标准结果(友好 message + 完整 plan),使模型看到与普通 tool 返回一致的结构。 + +**进入 Plan Mode —— 批准:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="enter_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**退出 Plan Mode —— 批准:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "approved"}, + ))], +) +``` + +**用户问答 —— 回答:** + +```python +Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="ask_user_question", + response={"status": "answered", "question_id": 1, "answer": "使用 PostgreSQL"}, + ))], +) +``` + +配合 [AG-UI](./agui.md) 时,长运行工具在续跑前不会发出 `TOOL_CALL_RESULT`;宿主在下一轮 run 中提交 function response。`STATE_SNAPSHOT` 事件暴露 `plan:`,便于实时计划面板。 + +## 进入方式 + +Plan Mode 有两种进入路径,对应不同的触发方与是否经过 `pending_enter` HITL: + +| 维度 | 方式 1:模型调用 `enter_plan_mode` | 方式 2:Session State 信号(UI 驱动) | +| --- | --- | --- | +| 触发方 | 模型判断任务需要规划 | 宿主 / UI 写入 session state | +| 初始状态 | `pending_enter` → HITL 确认 → `exploring` | 直接进入 `exploring` | +| 是否需要进入确认 | 是 | 否 | +| `enter_plan_mode` 工具 | 正常暴露(无 gate 时) | 从 schema 隐藏;若误调用返回 `PLAN_MODE_GATE` | + +### 方式 1:模型调用 `enter_plan_mode`(HITL 确认) + +**流程:** + +1. 模型判断任务需要规划(awareness prompt 引导) +2. 调用 `enter_plan_mode(objective="...")` +3. 状态变为 `pending_enter`,运行暂停,宿主展示确认卡片 +4. 用户批准后续跑,状态进入 `exploring`,写 gate 开启 + +### 方式 2:Session State 信号强制自动进入(UI 驱动) + +**流程:** + +1. 宿主在 session state 写入 `agent_mode = "plan"`(默认值,可通过 `PlanOptions` 自定义 key / value) +2. 下一次 invocation 时,`before_model` 触发 `_ensure_forced_plan` +3. 内部调用 `apply_enter`,**跳过** `pending_enter`,直接进入 `exploring` +4. `PlanToolSet` 从工具 schema 中隐藏 `enter_plan_mode`,避免重复 HITL + +**AG-UI 示例(`examples/plan_mode/static/index.html`):** + +用户在页面切换「Plan 模式」后,每次 run 将 `agent_mode` 写入 AG-UI 请求的 `state` 字段: + +```javascript +// 与 PlanOptions 默认值保持一致 +const FORCE_ENTER_PLAN_STATE_KEY = "agent_mode"; +const FORCE_ENTER_PLAN_STATE_VALUE = "plan"; + +function buildRunState() { + return { + [FORCE_ENTER_PLAN_STATE_KEY]: + agentMode === "plan" ? FORCE_ENTER_PLAN_STATE_VALUE : "agent", + }; +} + +// RunAgentInput.state = buildRunState() +``` + +切换为 Plan 模式后,下一条用户消息会自动进入 `exploring`,页面提示「无需确认 enter_plan_mode」。完整 Demo 见 [examples/plan_mode](../../../examples/plan_mode/)。 + +## 最佳实践 + +- **先挂只读探索能力** —— `FileToolSet` + `SpawnSubAgentTool(EXPLORE_AGENT, PLAN_AGENT)`,再 `setup_plan` +- **扩展 `write_tool_names`** —— agent 若挂载 MCP、Skills 或自定义写工具,将其 `tool.name` 加入 `PlanOptions.write_tool_names` +- **每个 agent 只 `setup_plan` 一次** —— 避免重复 callback +- **HITL 优先用 AG-UI** —— 浏览器卡片比 CLI 模拟 enter / approve / 问答更自然 +- **批准后跟踪步骤** —— 用 `todo_write` 或 `task_create`;Plan Mode 不替代步骤看板 +- **琐碎任务可跳过** —— awareness prompt 允许在说明理由后直接实施;一行修复不必强行走计划 + +## 完整示例 + +| 示例 | 说明 | +| --- | --- | +| [examples/plan_mode](../../../examples/plan_mode/) | orchestrator + `setup_plan` + AG-UI 浏览器 Demo(实时计划面板 + HITL 卡片) | + +安装 AG-UI 扩展:`pip install -e '.[ag-ui]'`,配置 `examples/plan_mode/.env`,运行 `python3 run_agent_with_agui.py`。 diff --git a/docs/mkdocs/zh/tool.md b/docs/mkdocs/zh/tool.md index 54cb3211..b7e2cd1d 100644 --- a/docs/mkdocs/zh/tool.md +++ b/docs/mkdocs/zh/tool.md @@ -33,9 +33,9 @@ Agent 通过以下步骤动态使用工具: | [Streaming Tools(流式工具)](#streaming-tools流式工具) | 实时预览长文本生成 | 使用 StreamingFunctionTool | 代码生成、文档写作 | | [WebFetchTool](#webfetchtool) | 抓取并文本化单个公网 URL | 实例化 WebFetchTool 并加入 tools | 阅读文档页、RFC、changelog、新闻 | | [WebSearchTool](#websearchtool) | 公网搜索引擎检索 | 实例化 WebSearchTool 并加入 tools | 实时资讯、版本发布、事实/定义查询 | -| [TodoWriteTool](#todowritetool-任务清单工具) | 多步任务规划与进度跟踪(整表替换) | 挂载 `TodoWriteTool` | 短清单、无依赖编排、token 不敏感 | -| [Task 工具族](#task-工具族结构化任务看板) | 结构化任务看板(按 id 增量更新 + 依赖) | 挂载 `TaskToolSet` | 长任务板、跨轮跟踪、blockedBy 依赖 | -| [Goal 工具族](#goal-工具族持久会话目标) | 单会话持久目标 + 强制收尾 | `setup_goal(agent)` | 跨轮大目标、宿主设目标、未完成不许收尾 | +| [TodoWriteTool](./tool_todowrite.md) | 多步任务规划与进度跟踪(整表替换) | 挂载 `TodoWriteTool` | 短清单、无依赖编排、token 不敏感 | +| [Task 工具族](./tool_task.md) | 结构化任务看板(按 id 增量更新 + 依赖) | 挂载 `TaskToolSet` | 长任务板、跨轮跟踪、blockedBy 依赖 | +| [Goal 工具族](./goal.md) | 单会话持久目标 + 强制收尾 | `setup_goal(agent)` | 跨轮大目标、宿主设目标、未完成不许收尾 | | [Agent Code Executor](./code_executor.md) | 自动生成并执行代码场景、数据处理场景 | 配置 CodeExecutor | API 自动调用、表格数据处理 | --- @@ -2848,356 +2848,3 @@ DuckDuckGo provider 在命中 instant answer 时,`summary` 字段会包含 DDG - **DuckDuckGo 原始命中**(`ddg_raw_agent`,`dedup_urls=False`):保留 provider 原始召回列表,便于下游处理 - **Google 基线**(`google_agent`,`safe=active`):真实公网搜索 + 服务端单域 `siteSearch` + 客户端多域过滤 + 黑名单 + per-call `lang` 覆盖 - **Google 时效性 Agent**(`google_raw_agent`,`dateRestrict=m6` + `dedup_urls=False`):只保留过去 6 个月索引的结果,适合"最新/what's new"类查询 - ---- - -## TodoWriteTool(任务清单工具) - -`TodoWriteTool` 是框架内置的**结构化任务清单工具**,对齐 Claude Code / DeepAgents 的 `TodoWrite` 语义:模型通过单次 `todo_write` 调用发送**完整、更新后的清单**,工具校验后整体替换上一份清单,并将会话级 state 持久化,从而在多轮 `Runner.run_async` 之间保持计划与进度。 - -适合**步骤较少、无显式依赖边、希望实现简单**的场景。若需要服务端分配 id、按 `taskId` 增量 patch、或 `blockedBy` / `blocks` 依赖编排,请改用下文 [Task 工具族](#task-工具族结构化任务看板)。 - -### 功能特性 - -- **整表替换**:每次调用传入完整 `todos` 数组,新列表**完全覆盖**旧列表(不做智能 merge);唯一合法的清空方式是显式传入 `todos: []` -- **会话级持久化**:清单序列化为 JSON 写入 `tool_context.state["todos[:]"]`(默认前缀 `todos`,**勿用** `temp:`——该前缀会被 `BaseSessionService` 剥离且不持久化) -- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立清单 -- **硬契约校验(代码强制)**:`content` / `activeForm` 非空、至多一个 `in_progress`、`content` 全局唯一;违反时返回 `INVALID_ARGS` / `INVALID_TODOS` -- **Prompt 引导分层**:`DEFAULT_TODO_PROMPT` 经 `process_request` 自动注入 system instruction,描述使用时机与写法;与硬契约分离 -- **响应带回 diff**:成功时返回 `{message, todos, oldTodos}`,便于前端 / CLI 直接渲染当前清单与变更 -- **可选策略钩子**:`nudge_hooks` 只读回调,可在成功响应 `message` 末尾追加策略提示(不得修改清单) -- **全部完成后自动清空**:`clear_on_all_done=True`(默认)时,若传入列表全部为 `completed`,持久化为空列表,避免历史项堆积 - -### TodoWriteTool 参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"todos"` | state key 前缀;勿使用 `temp:` | -| `clear_on_all_done` | `bool` | `True` | 全部为 `completed` 时是否清空持久化列表 | -| `default_nudge` | `str` | 内置文案 | 每次成功响应的基础提示语 | -| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | 只读策略钩子列表 | -| `filters_name` / `filters` | — | `None` | 透传给 `BaseTool` 的 Filter | - -**LLM 调用参数**(`todo_write`): - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `todos` | `array` | 是 | 完整清单;每项含 `content`(祈使句)、`activeForm`(进行时)、`status`(`pending` / `in_progress` / `completed`) | - -**成功响应字段**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `message` | `str` | 基础 nudge + 钩子追加文案 | -| `todos` | `array` | 持久化后的当前清单 | -| `oldTodos` | `array \| null` | 更新前的清单(首次写入为 `null`) | - -### 使用方式 - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TodoWriteTool - -agent = LlmAgent( - name="todo_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="你是规划型助手,多步任务请用 todo_write 维护清单。", - tools=[TodoWriteTool()], -) -``` - -服务端 / 审计读取当前清单: - -```python -from trpc_agent_sdk.tools import get_todos, render_todos - -todos = get_todos(session, branch=agent.name) -print(render_todos(todos)) # ✅ / 🔄 / ⬜ 纯文本 checklist -``` - -### TodoWriteTool 与 Task 工具族对比 - -| 维度 | `TodoWriteTool` | `TaskToolSet` | -| --- | --- | --- | -| 工具数量 | 1(`todo_write`) | 4(`task_create` / `task_update` / `task_get` / `task_list`) | -| 更新方式 | 整表替换 | 按 `taskId` 增量 patch | -| 单项标识 | `content`(唯一键) | `id`(服务端分配) | -| 依赖编排 | 无 | `blockedBy` / `blocks`,完成上游自动 unblock | -| state key | `todos[:branch]` | `tasks[:branch]` | -| 并行 tool 调用 | 整表覆盖,天然 last-write-wins | 内置 `task_store_lock` 串行化 RMW | - -> **建议二选一挂载**;同时挂载易让模型混用两套语义。 - -### TodoWriteTool 完整示例 - -见 [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py):同一 session 内多轮「规划 → 逐项完成」,每轮用 `get_todos` 读回持久化清单。 - ---- - -## Task 工具族(结构化任务看板) - -`TaskToolSet` 暴露四个工具——`task_create`、`task_update`、`task_get`、`task_list`——对齐 Claude Code v2.1.142+ 的结构化 Task 能力。与 `TodoWriteTool` 的整表替换不同,Task 工具族采用**按服务端分配的 `id` 增量更新**:创建时返回 id,后续用 `task_update` 局部修改状态、字段或依赖边。 - -整个看板序列化为**单个 JSON blob** 写入 `tool_context.state["tasks[:]"]`,跨轮存活;`highwatermark` 记录曾分配的最高 id,软删除(`status: deleted`)后**不会复用 id**。 - -### 功能特性 - -- **增量更新**:`task_create` 分配 id;`task_update` 按 `taskId` patch,无需重传整板 -- **依赖编排**:`addBlockedBy` / `removeBlockedBy`(及 `addBlocks` / `removeBlocks`)维护双向边;上游 `completed` 时自动从下游 `blockedBy` 移除并返回 `unblocked` -- **Token 优化**:`task_list` 只返回摘要(省略 `description`);完整详情用 `task_get` -- **硬契约校验**:`subject` 非空、状态合法、依赖存在、**无环**(`detect_cycle`)、默认**至多一个 `in_progress`**(`enforce_single_in_progress`,可关) -- **并发安全**:`_TaskToolBase` 在 load → mutate → save 外包 `task_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` 下同批并行调用 -- **Prompt 自动注入**:`DEFAULT_TASK_PROMPT` 多工具挂载时只注入一次 - -### TaskToolSet 构造参数 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"tasks"` | state key 前缀;勿使用 `temp:` | -| `enforce_single_in_progress` | `bool` | `True` | 设置某任务 `in_progress` 时,若已有其他 `in_progress` 则拒绝 | -| `inject_prompt` | `bool` | `True` | 是否向 system instruction 注入 `DEFAULT_TASK_PROMPT` | - -### 四个工具的 LLM 参数概要 - -**`task_create`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `subject` | 是 | 短标题(祈使句) | -| `description` | 否 | 自由文本详情 | -| `activeForm` | 否 | 进行时文案 | -| `metadata` | 否 | 扩展键值 | - -返回 `{task: {id, subject}, message}`。 - -**`task_update`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `taskId` | 是 | 要更新的任务 id | -| `status` | 否 | `pending` / `in_progress` / `completed` / `deleted` | -| `subject` / `description` / `activeForm` / `owner` / `metadata` | 否 | 标量字段 patch | -| `addBlockedBy` / `removeBlockedBy` | 否 | 上游依赖 id 列表 | -| `addBlocks` / `removeBlocks` | 否 | 下游阻塞 id 列表 | - -返回 `{task, unblocked, message}`;`unblocked` 为因本次完成而解除阻塞的 pending 任务 id 列表。 - -**`task_get`**:`taskId`(必填)→ 含 `description` 的完整记录。 - -**`task_list`**:可选 `includeDeleted`;返回 `{tasks, stats}`,摘要不含 `description`。 - -**常见错误码**:`INVALID_ARGS`、`INVALID_DEPENDENCY`、`INVALID_STATUS`、`NOT_FOUND`。 - -### 使用方式 - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools import TaskToolSet - -agent = LlmAgent( - name="task_planner", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="多步项目请用 task_create / task_update 维护看板。", - tools=[TaskToolSet()], - # parallel_tool_calls=True 时,同批多个 task 工具由 task_store_lock 保护 store 一致性 -) -``` - -读回持久化看板(REST / 审计 / demo 收尾): - -```python -from trpc_agent_sdk.tools import get_task_store, render_task_list - -store = get_task_store(session, branch=agent.name) -print(render_task_list(store)) -# ✅ #1 已完成 -# 🔄 #2 进行中 -# ⬜ #3 待办 (blocked by: 2) -``` - -### 依赖与解锁示例 - -```text -#1 设计表结构 - ├──→ #2 实现 API ──→ #3 单元测试 - └──→ #4 编写文档 - -#1 completed → unblocked: ['2', '4'] -#2 completed → unblocked: ['3'] -``` - -### Task 工具族最佳实践 - -- **规划与执行分离**:先 `task_create` 建板并 `addBlockedBy`,再逐项 `in_progress` → `completed` -- **不要编造 id**:只使用 `task_create` 返回的 id -- **并行调用**:开启 `parallel_tool_calls=True` 时,同 board 上的并发 `task_create` / `task_update` 由锁串行化;不同 `branch` 仍并行 -- **与 TodoWrite 二选一**:长板 + 依赖用 Task;短清单用 TodoWrite - -### Task 工具族完整示例 - -| 示例 | 说明 | -| --- | --- | -| [examples/task_tools](../../../examples/task_tools/) | 多轮对话:依赖编排、逐项完成、跨轮 `get_task_store` 读回看板 | -| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | 验证 `parallel_tool_calls` 与 `task_store_lock`(Phase 1–2 无需 API Key) | - ---- - -## Goal 工具族(持久会话目标) - -`GoalToolSet` 暴露三个工具——`create_goal`、`get_goal`、`update_goal`——对齐 Claude Code 的 **Session Goal** 能力。与 `TodoWriteTool`(多行待办)和 `TaskToolSet`(多任务看板)不同,Goal 在每个 session branch 上**至多只有一个**持久目标:在目标为 `active` 期间,模型给出「看起来像最终答案」的文本**不算完成**——必须继续执行,或显式调用 `update_goal('complete' | 'blocked')` 收尾。 - -目标序列化为**单个 JSON blob**(`GoalRecord`)写入 `tool_context.state["goal[:]"]`,跨 `Runner.run_async` 调用存活。除三个模型工具外,完整能力还需通过 `setup_goal()` 挂载一对 **enforcement callbacks**(`before_model` / `after_model`),在**同一次 invocation 内**拦截过早的最终回复并自动重试。 - -### 功能特性 - -- **单目标契约**:每个 branch 一个 `GoalRecord`(`objective` + 三态 `active` / `complete` / `blocked`);`complete` / `blocked` 为**不可逆**终态 -- **跨轮持久化**:随 function-response 的 state delta 落库;**勿用** `temp:` 前缀 -- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立目标 -- **强制收尾(enforcement)**:目标 `active` 时,`after_model` 检测「无 tool call、有可见文本、非 partial」的过早 final,抑制该回复并在同 invocation 内 re-run;`before_model` 注入 user-role nudge -- **fail-open 预算**:`max_retries`(默认 3)次拦截后放行最终回复,避免无限循环;计数器存在 invocation 级 `agent_context.metadata`,不持久化 -- **双入口创建**: - - **模型侧**:`create_goal(objective=...)` —— LLM 判断多步任务后自主创建 - - **宿主侧**:`start_goal(session_service, ...)` —— 应用层在首轮前写入 session,模型无需调用 `create_goal` -- **Prompt 引导分层**:`DEFAULT_GUIDANCE` 在目标 active 时经 `before_model` 注入 system instruction(`inject_guidance=True`);硬约束由 store 校验 + callback 共同保证 -- **并发安全**:`_GoalToolBase` 在 load → mutate → save 外包 `goal_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` - -### 与 Todo / Task 的关系 - -| 维度 | `TodoWriteTool` | `TaskToolSet` | Goal 工具族 | -| --- | --- | --- | --- | -| 粒度 | 多行待办清单 | 多任务看板 + 依赖 | **单个**会话目标 | -| 更新方式 | 整表替换 | 按 `taskId` 增量 | `create_goal` / `update_goal` | -| 未完成能否收尾 | Prompt 引导 | Prompt 引导 | **callback 强制拦截** | -| state key | `todos[:branch]` | `tasks[:branch]` | `goal[:branch]` | -| 典型用途 | 步骤可视、短清单 | 长看板、依赖编排 | 整件事是否算做完 | - -> Todo / Task 管「步骤分解」,Goal 管「整体完成契约」。可组合使用,但避免让模型同时混用过多规划工具。 - -### GoalOptions 构造参数 - -通过 `setup_goal(agent, GoalOptions(...))` 配置: - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `state_key_prefix` | `str` | `"goal"` | state key 前缀;勿使用 `temp:` | -| `inject_guidance` | `bool` | `True` | 是否在 `before_model` 向 system instruction 注入 `DEFAULT_GUIDANCE` | -| `guidance` | `str` | `DEFAULT_GUIDANCE` | 注入的长文案(含串行调用 goal 工具等约定) | -| `max_retries` | `int` | `3` | 同 invocation 内拦截过早 final 的预算;耗尽后 fail-open | -| `nudge_template` | `str` | `DEFAULT_NUDGE` | 拦截后以 user-role 追加的提醒模板(支持 `{attempt}` / `{max_retries}` / `{objective}`) | -| `on_retry` | `Callable[[RetryEvent], None]` | `None` | 每次拦截或预算耗尽时的可观测回调 | - -仅挂载模型工具、不要 enforcement 时,可直接 `tools=[GoalToolSet()]`,但不具备「未完成不许收尾」能力。 - -### 三个工具的 LLM 参数概要 - -**`create_goal`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `objective` | 是 | 完成标准——「done」具体指什么 | - -成功返回 `{message, goal}`;若已有 `active` 目标返回 `{error: "INVALID_STATE: ..."}`。 - -**`get_goal`** - -无参数。有目标时返回 `{message, goal}`;无目标时返回 `{message: "No session goal is set."}`。 - -**`update_goal`** - -| 参数 | 必填 | 说明 | -|------|------|------| -| `status` | 是 | `complete`(目标已达成)或 `blocked`(同一阻塞条件反复出现、无用户输入无法继续) | - -成功返回 `{message, goal}`;无 active 目标或已是终态时返回 `{error: "INVALID_STATE: ..."}`。 - -**`GoalRecord` 字段**(JSON 使用 camelCase 别名持久化): - -| 字段 | 说明 | -|------|------| -| `id` | 服务端分配的 uuid | -| `objective` | 完成标准文本 | -| `status` | `active` / `complete` / `blocked` | -| `createdAtUnix` / `updatedAtUnix` | 创建 / 最后更新时间(unix 秒) | -| `terminalAtUnix` | 进入终态的时间(可选) | - -### enforcement 工作流程 - -```text -模型输出 final 文本(无 tool call,goal 仍 active) - ↓ -after_model 判定为 premature final - ↓ -抑制该 final(不提交为答案),retry_count += 1 -before_model 注入 nudge,同 invocation 继续 agent loop - ↓ -retry_count >= max_retries → fail-open,on_retry(reason="exhausted") -``` - -拦截条件(`_is_premature_final`):非 partial、无 error、content 含可见文本,且**不含** `function_call` / `function_response`。 - -### 使用方式 - -推荐一行挂载工具 + callbacks: - -```python -from trpc_agent_sdk.agents import LlmAgent -from trpc_agent_sdk.models import OpenAIModel -from trpc_agent_sdk.tools.goal_tools import GoalOptions, RetryEvent, setup_goal - -def on_retry(event: RetryEvent) -> None: - if event.reason == "blocked": - print(f"拦截过早收尾 (attempt {event.attempt_number}/{event.max_retries})") - -agent = LlmAgent( - name="goal_agent", - model=OpenAIModel(model_name="...", api_key="...", base_url="..."), - instruction="多步工程任务请用 goal 工具跟踪完成状态。", - tools=[...], # 业务工具,如 BashTool / WriteTool -) -setup_goal(agent, GoalOptions(max_retries=3, on_retry=on_retry)) -``` - -宿主在首轮前预注入目标(模型不调用 `create_goal`): - -```python -from trpc_agent_sdk.tools.goal_tools import start_goal - -goal = await start_goal( - session_service, - app_name="my_app", - user_id="user_1", - session_id=session_id, - objective="在当前目录创建 notes/ 并写入 summary.txt 与 example.py", - agent_name=agent.name, # 与 LlmAgent.name 一致,用于 branch 隔离 -) -``` - -读回持久化目标(REST / 审计 / demo 收尾): - -```python -from trpc_agent_sdk.tools.goal_tools import get_goal_record, render_goal - -goal = get_goal_record(session, branch=agent.name) -print(render_goal(goal)) -# ✅ Goal [complete] -# objective: ... -# created: 1782893110 -# terminal: 1782893116 -``` - -### Goal 工具族最佳实践 - -- **用 `setup_goal` 而非只挂 `GoalToolSet`**:只有 callbacks 才能实现「active 期间不许 final」 -- **模型侧 vs 宿主侧**:Slash command、`/goal`、配置驱动任务用 `start_goal()`;让模型自主判断多步任务时用 `create_goal` -- **一次响应只调一个 goal 工具**:`DEFAULT_GUIDANCE` 要求串行语义;不要同轮 `create_goal` + `update_goal` -- **`blocked` 慎用**:仅当同一阻塞条件跨多次尝试仍无法推进、且需要用户输入或外部状态变化时使用;不要因为任务难、慢或不完整就标记 blocked -- **可观测性**:通过 `on_retry` 记录 `⚡ Premature final intercepted` 与预算耗尽,便于调优 prompt 或 `max_retries` -- **与 Todo / Task 分工**:Todo / Task 展示步骤与依赖;Goal 约束「整件事是否已真正完成」 - -### Goal 工具族完整示例 - -| 示例 | 说明 | -| --- | --- | -| [examples/goal_tools](../../../examples/goal_tools/) | Case 1:模型 `create_goal`;Case 2:宿主 `start_goal` 预注入;演示 enforcement 拦截与 `update_goal(complete)` | diff --git a/docs/mkdocs/zh/tool_task.md b/docs/mkdocs/zh/tool_task.md new file mode 100644 index 00000000..aa7826f7 --- /dev/null +++ b/docs/mkdocs/zh/tool_task.md @@ -0,0 +1,108 @@ +## Task 工具族(结构化任务看板) + +`TaskToolSet` 暴露四个工具——`task_create`、`task_update`、`task_get`、`task_list`——对齐 Claude Code v2.1.142+ 的结构化 Task 能力。与 `TodoWriteTool` 的整表替换不同,Task 工具族采用**按服务端分配的 `id` 增量更新**:创建时返回 id,后续用 `task_update` 局部修改状态、字段或依赖边。 + +整个看板序列化为**单个 JSON blob** 写入 `tool_context.state["tasks[:]"]`,跨轮存活;`highwatermark` 记录曾分配的最高 id,软删除(`status: deleted`)后**不会复用 id**。 + +### 功能特性 + +- **增量更新**:`task_create` 分配 id;`task_update` 按 `taskId` patch,无需重传整板 +- **依赖编排**:`addBlockedBy` / `removeBlockedBy`(及 `addBlocks` / `removeBlocks`)维护双向边;上游 `completed` 时自动从下游 `blockedBy` 移除并返回 `unblocked` +- **Token 优化**:`task_list` 只返回摘要(省略 `description`);完整详情用 `task_get` +- **硬契约校验**:`subject` 非空、状态合法、依赖存在、**无环**(`detect_cycle`)、默认**至多一个 `in_progress`**(`enforce_single_in_progress`,可关) +- **并发安全**:`_TaskToolBase` 在 load → mutate → save 外包 `task_store_lock`(按 session + branch),兼容 `parallel_tool_calls=True` 下同批并行调用 +- **Prompt 自动注入**:`DEFAULT_TASK_PROMPT` 多工具挂载时只注入一次 + +### TaskToolSet 构造参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"tasks"` | state key 前缀;勿使用 `temp:` | +| `enforce_single_in_progress` | `bool` | `True` | 设置某任务 `in_progress` 时,若已有其他 `in_progress` 则拒绝 | +| `inject_prompt` | `bool` | `True` | 是否向 system instruction 注入 `DEFAULT_TASK_PROMPT` | + +### 四个工具的 LLM 参数概要 + +**`task_create`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `subject` | 是 | 短标题(祈使句) | +| `description` | 否 | 自由文本详情 | +| `activeForm` | 否 | 进行时文案 | +| `metadata` | 否 | 扩展键值 | + +返回 `{task: {id, subject}, message}`。 + +**`task_update`** + +| 参数 | 必填 | 说明 | +|------|------|------| +| `taskId` | 是 | 要更新的任务 id | +| `status` | 否 | `pending` / `in_progress` / `completed` / `deleted` | +| `subject` / `description` / `activeForm` / `owner` / `metadata` | 否 | 标量字段 patch | +| `addBlockedBy` / `removeBlockedBy` | 否 | 上游依赖 id 列表 | +| `addBlocks` / `removeBlocks` | 否 | 下游阻塞 id 列表 | + +返回 `{task, unblocked, message}`;`unblocked` 为因本次完成而解除阻塞的 pending 任务 id 列表。 + +**`task_get`**:`taskId`(必填)→ 含 `description` 的完整记录。 + +**`task_list`**:可选 `includeDeleted`;返回 `{tasks, stats}`,摘要不含 `description`。 + +**常见错误码**:`INVALID_ARGS`、`INVALID_DEPENDENCY`、`INVALID_STATUS`、`NOT_FOUND`。 + +### 使用方式 + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TaskToolSet + +agent = LlmAgent( + name="task_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="多步项目请用 task_create / task_update 维护看板。", + tools=[TaskToolSet()], + # parallel_tool_calls=True 时,同批多个 task 工具由 task_store_lock 保护 store 一致性 +) +``` + +读回持久化看板(REST / 审计 / demo 收尾): + +```python +from trpc_agent_sdk.tools import get_task_store, render_task_list + +store = get_task_store(session, branch=agent.name) +print(render_task_list(store)) +# ✅ #1 已完成 +# 🔄 #2 进行中 +# ⬜ #3 待办 (blocked by: 2) +``` + +### 依赖与解锁示例 + +```text +#1 设计表结构 + ├──→ #2 实现 API ──→ #3 单元测试 + └──→ #4 编写文档 + +#1 completed → unblocked: ['2', '4'] +#2 completed → unblocked: ['3'] +``` + +### Task 工具族最佳实践 + +- **规划与执行分离**:先 `task_create` 建板并 `addBlockedBy`,再逐项 `in_progress` → `completed` +- **不要编造 id**:只使用 `task_create` 返回的 id +- **并行调用**:开启 `parallel_tool_calls=True` 时,同 board 上的并发 `task_create` / `task_update` 由锁串行化;不同 `branch` 仍并行 +- **与 TodoWrite 二选一**:长板 + 依赖用 Task;短清单用 TodoWrite + +### Task 工具族完整示例 + +| 示例 | 说明 | +| --- | --- | +| [examples/task_tools](../../../examples/task_tools/) | 多轮对话:依赖编排、逐项完成、跨轮 `get_task_store` 读回看板 | +| [examples/task_tools_parallel](../../../examples/task_tools_parallel/) | 验证 `parallel_tool_calls` 与 `task_store_lock`(Phase 1–2 无需 API Key) | + +--- diff --git a/docs/mkdocs/zh/tool_todowrite.md b/docs/mkdocs/zh/tool_todowrite.md new file mode 100644 index 00000000..6799ad02 --- /dev/null +++ b/docs/mkdocs/zh/tool_todowrite.md @@ -0,0 +1,83 @@ +## TodoWriteTool(任务清单工具) + +`TodoWriteTool` 是框架内置的**结构化任务清单工具**,对齐 Claude Code / DeepAgents 的 `TodoWrite` 语义:模型通过单次 `todo_write` 调用发送**完整、更新后的清单**,工具校验后整体替换上一份清单,并将会话级 state 持久化,从而在多轮 `Runner.run_async` 之间保持计划与进度。 + +适合**步骤较少、无显式依赖边、希望实现简单**的场景。若需要服务端分配 id、按 `taskId` 增量 patch、或 `blockedBy` / `blocks` 依赖编排,请改用 [Task 工具族](./tool_task.md)。 + +### 功能特性 + +- **整表替换**:每次调用传入完整 `todos` 数组,新列表**完全覆盖**旧列表(不做智能 merge);唯一合法的清空方式是显式传入 `todos: []` +- **会话级持久化**:清单序列化为 JSON 写入 `tool_context.state["todos[:]"]`(默认前缀 `todos`,**勿用** `temp:`——该前缀会被 `BaseSessionService` 剥离且不持久化) +- **子 Agent 隔离**:state key 追加 `:`,父 / 子 Agent 各自维护独立清单 +- **硬契约校验(代码强制)**:`content` / `activeForm` 非空、至多一个 `in_progress`、`content` 全局唯一;违反时返回 `INVALID_ARGS` / `INVALID_TODOS` +- **Prompt 引导分层**:`DEFAULT_TODO_PROMPT` 经 `process_request` 自动注入 system instruction,描述使用时机与写法;与硬契约分离 +- **响应带回 diff**:成功时返回 `{message, todos, oldTodos}`,便于前端 / CLI 直接渲染当前清单与变更 +- **可选策略钩子**:`nudge_hooks` 只读回调,可在成功响应 `message` 末尾追加策略提示(不得修改清单) +- **全部完成后自动清空**:`clear_on_all_done=True`(默认)时,若传入列表全部为 `completed`,持久化为空列表,避免历史项堆积 + +### TodoWriteTool 参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `state_key_prefix` | `str` | `"todos"` | state key 前缀;勿使用 `temp:` | +| `clear_on_all_done` | `bool` | `True` | 全部为 `completed` 时是否清空持久化列表 | +| `default_nudge` | `str` | 内置文案 | 每次成功响应的基础提示语 | +| `nudge_hooks` | `Optional[List[NudgeHook]]` | `None` | 只读策略钩子列表 | +| `filters_name` / `filters` | — | `None` | 透传给 `BaseTool` 的 Filter | + +**LLM 调用参数**(`todo_write`): + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `todos` | `array` | 是 | 完整清单;每项含 `content`(祈使句)、`activeForm`(进行时)、`status`(`pending` / `in_progress` / `completed`) | + +**成功响应字段**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `message` | `str` | 基础 nudge + 钩子追加文案 | +| `todos` | `array` | 持久化后的当前清单 | +| `oldTodos` | `array \| null` | 更新前的清单(首次写入为 `null`) | + +### 使用方式 + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import TodoWriteTool + +agent = LlmAgent( + name="todo_planner", + model=OpenAIModel(model_name="...", api_key="...", base_url="..."), + instruction="你是规划型助手,多步任务请用 todo_write 维护清单。", + tools=[TodoWriteTool()], +) +``` + +服务端 / 审计读取当前清单: + +```python +from trpc_agent_sdk.tools import get_todos, render_todos + +todos = get_todos(session, branch=agent.name) +print(render_todos(todos)) # ✅ / 🔄 / ⬜ 纯文本 checklist +``` + +### TodoWriteTool 与 Task 工具族对比 + +| 维度 | `TodoWriteTool` | `TaskToolSet` | +| --- | --- | --- | +| 工具数量 | 1(`todo_write`) | 4(`task_create` / `task_update` / `task_get` / `task_list`) | +| 更新方式 | 整表替换 | 按 `taskId` 增量 patch | +| 单项标识 | `content`(唯一键) | `id`(服务端分配) | +| 依赖编排 | 无 | `blockedBy` / `blocks`,完成上游自动 unblock | +| state key | `todos[:branch]` | `tasks[:branch]` | +| 并行 tool 调用 | 整表覆盖,天然 last-write-wins | 内置 `task_store_lock` 串行化 RMW | + +> **建议二选一挂载**;同时挂载易让模型混用两套语义。 + +### TodoWriteTool 完整示例 + +见 [examples/todo_tool/run_agent.py](../../../examples/todo_tool/run_agent.py):同一 session 内多轮「规划 → 逐项完成」,每轮用 `get_todos` 读回持久化清单。 + +--- diff --git a/examples/plan_mode/.env b/examples/plan_mode/.env new file mode 100644 index 00000000..dc791393 --- /dev/null +++ b/examples/plan_mode/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/plan_mode/README.md b/examples/plan_mode/README.md new file mode 100644 index 00000000..ea0bc33c --- /dev/null +++ b/examples/plan_mode/README.md @@ -0,0 +1,100 @@ +# Plan Mode 示例 + +演示 tRPC-Agent-Python 的 **Plan Mode**:用 `setup_plan()` 给 LLM Agent 装上「先规划、后实施」的工作流,并配合 `SpawnSubAgentTool`(Explore / Plan 两种子 agent 原型)完成代码探索与方案设计。 + +## 它解决什么问题 + +普通 coding agent 容易上来就改文件、边写边改方向。Plan Mode 把流程拆成两段: + +1. **规划阶段** —— 模型只能用只读工具(Read / Grep / Glob)和子 agent(Explore / Plan)调研代码、撰写计划文档;写文件、改代码、执行命令等「副作用」工具被自动 gate,直到计划被人工批准。 +2. **实施阶段** —— 计划通过后,写工具自动解锁,模型按照批准的计划落地实现,并可用 `todo_write` 跟踪进度。 + +规划期间需要人工介入的三个动作——`enter_plan_mode` / `exit_plan_mode` / `ask_user_question`——都是 `LongRunningFunctionTool`,运行会暂停等待人类响应。这种交互用浏览器页面承载比 CLI 更自然,所以本示例同时提供了一个 AG-UI 服务端和一张零依赖的静态页面。 + +## 目录结构 + +``` +plan_mode/ +├── agent/ +│ ├── agent.py # orchestrator agent 定义:FileToolSet + SpawnSubAgentTool + TodoWriteTool + setup_plan +│ ├── prompts.py # system instruction +│ └── __init__.py +├── static/ +│ └── index.html # AG-UI 浏览器 Demo(单文件,无构建依赖) +├── run_agent_with_agui.py # FastAPI + AG-UI 服务端,托管 agent 接口与静态页 +├── .env # 模型配置(TRPC_AGENT_API_KEY / BASE_URL / MODEL_NAME) +└── README.md +``` + +## 架构 + +``` +orchestrator (LlmAgent + setup_plan) +├── FileToolSet # Read/Grep/Glob 始终可用;Write/Edit/Bash 在计划批准后解锁 +├── SpawnSubAgentTool(EXPLORE_AGENT, PLAN_AGENT) # 只读调研与方案设计子 agent +├── TodoWriteTool # 实施阶段用于跟踪进度 +└── PlanToolSet # enter_plan_mode / update_plan_content / exit_plan_mode / ask_user_question +``` + +- 计划文档持久化在**主 agent 的 session** 中(`state["plan"]`)。 +- 被 spawn 出来的子 agent 只返回文本,不直接改动主 agent 的状态。 + +## 前置条件 + +```bash +# 1. 安装 SDK(含 AG-UI 依赖) +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +python3 -m venv .venv +source .venv/bin/activate +pip3 install -e . + +# 2. 配置模型访问 +# 复制并填写 examples/plan_mode/.env: +TRPC_AGENT_API_KEY=<你的 key> +TRPC_AGENT_BASE_URL=<可选,自定义 endpoint> +TRPC_AGENT_MODEL_NAME=<可选,默认 gpt-4.1-mini> +``` + +## 运行 + +```bash +cd examples/plan_mode + +# 完整 agent + 基于 AG-UI 的浏览器页面(推荐) +python3 run_agent_with_agui.py + +# 自定义监听地址 / 端口 +python3 run_agent_with_agui.py --host 0.0.0.0 --port 8080 +``` + +启动后控制台会打印三个地址: + +``` +Plan Mode AG-UI demo: http://127.0.0.1:18090/ +Agent endpoint: http://127.0.0.1:18090/plan_agent +Health check: http://127.0.0.1:18090/health +``` + +> 未设置 `TRPC_AGENT_API_KEY` 时服务仍能启动,但会打印警告,agent 调用会失败。 + +## 浏览器 Demo 说明 + +打开 ——静态页面和 agent 接口(`/plan_agent`)由同一个 FastAPI 应用提供,因此页面可同源调用接口,无需任何 CORS 配置。 + +页面提供: + +- **聊天面板** —— 与 orchestrator 对话。 +- **实时计划面板** —— 根据 AG-UI 每次运行结束发送的 `STATE_SNAPSHOT` 事件更新(解析 `plan:orchestrator` 这个 session state key,见 `trpc_agent_sdk/plan_mode/_helpers.py:state_key`)。 + +三种 HITL 交互的页面表现: + +| 模型动作 | 页面表现 | 用户操作后续跑格式 | +|---|---|---| +| `enter_plan_mode` | 展示「确认进入 Plan Mode」卡片 | 确认后进入 `exploring` 状态,开启写工具 gate | +| `exit_plan_mode` | 展示「通过 / 拒绝计划」卡片 | `{"role":"tool","toolCallId":...,"content":"{\"status\":\"approved\",...}"}` | +| `ask_user_question` | 展示问题与选项卡片 | 同上;问题文本/选项/`question_id` 取自状态快照中的 `askedQuestions` | + +> `exit_plan_mode` 触发时本次运行会在没有 `TOOL_CALL_RESULT` 的情况下结束——这是 AG-UI 表示「长时间运行的工具调用被暂停」的方式。点击按钮续跑的报文格式,与任何宿主应用需满足的 `process_hitl_function_response` 期望格式一致。 + +静态页 `static/index.html` 是一个**无任何依赖的单文件**(无需构建、无需 npm install),可直接在浏览器打开。如需用 Node 驱动同一接口(例如写测试脚本),参考 [examples/agui/client_js](../agui/client_js) 中的 `@ag-ui/client` 写法。 diff --git a/examples/plan_mode/agent/__init__.py b/examples/plan_mode/agent/__init__.py new file mode 100644 index 00000000..17f6fa68 --- /dev/null +++ b/examples/plan_mode/agent/__init__.py @@ -0,0 +1,9 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from .agent import create_plan_agent + +__all__ = ["create_plan_agent"] diff --git a/examples/plan_mode/agent/agent.py b/examples/plan_mode/agent/agent.py new file mode 100644 index 00000000..944bd7da --- /dev/null +++ b/examples/plan_mode/agent/agent.py @@ -0,0 +1,46 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.plan_mode import setup_plan +from trpc_agent_sdk.tools import FileToolSet +from trpc_agent_sdk.tools import SpawnSubAgentTool +from trpc_agent_sdk.tools import TodoWriteTool + +from .prompts import SYSTEM_INSTRUCTION + +load_dotenv() + + +def create_plan_agent() -> LlmAgent: + model = OpenAIModel( + model_name=os.environ.get("TRPC_AGENT_MODEL_NAME", "gpt-4.1-mini"), + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url=os.environ.get("TRPC_AGENT_BASE_URL"), + ) + agent = LlmAgent( + name="orchestrator", + model=model, + instruction=SYSTEM_INSTRUCTION, + tools=[ + # Full file toolset: write/edit/bash are gated by setup_plan() + # while a plan is active and unlock automatically on approval. + FileToolSet(), + SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT]), + # todo_write is gated during plan mode; use after approval to track implementation. + TodoWriteTool(), + ], + ) + setup_plan(agent) + return agent diff --git a/examples/plan_mode/agent/prompts.py b/examples/plan_mode/agent/prompts.py new file mode 100644 index 00000000..5fa22cd1 --- /dev/null +++ b/examples/plan_mode/agent/prompts.py @@ -0,0 +1,8 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +SYSTEM_INSTRUCTION = """\ +You are a coding assistant. Prefer reusing existing code and patterns in the workspace.""" diff --git a/examples/plan_mode/run_agent_with_agui.py b/examples/plan_mode/run_agent_with_agui.py new file mode 100644 index 00000000..dae47d02 --- /dev/null +++ b/examples/plan_mode/run_agent_with_agui.py @@ -0,0 +1,132 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""AG-UI server for the Plan Mode example. + +Exposes the plan_mode orchestrator (see agent/agent.py) over the AG-UI +protocol and serves a small, dependency-free static HTML page +(static/index.html) from the same FastAPI app so it can call the agent +endpoint same-origin (no CORS setup needed). + +The page lets a human watch the plan document update live and +approve / reject / answer questions from the browser instead of +simulating the decision in Python. + +Run: + cd examples/plan_mode + python3 run_agent_with_agui.py + +Then open http://127.0.0.1:18090/ in a browser. + +Prerequisites: + pip install -e '.[ag-ui]' + Set TRPC_AGENT_API_KEY (and optionally TRPC_AGENT_BASE_URL / TRPC_AGENT_MODEL_NAME) + in examples/plan_mode/.env +""" + +from __future__ import annotations + +import argparse +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.server.ag_ui import AgUiAgent +from trpc_agent_sdk.server.ag_ui import AgUiManager +from trpc_agent_sdk.server.ag_ui import AgUiService + +_EXAMPLE_DIR = Path(__file__).resolve().parent +if str(_EXAMPLE_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLE_DIR)) + +load_dotenv(_EXAMPLE_DIR / ".env") + +HOST = "127.0.0.1" +PORT = 18090 +APP_NAME = "plan_mode_agui_demo" +AGENT_URI = "/plan_agent" +STATIC_DIR = _EXAMPLE_DIR / "static" +INDEX_HTML = STATIC_DIR / "index.html" + +agui_manager = AgUiManager() + + +class HealthResponse(BaseModel): + """Response body for GET /health.""" + + status: str = "ok" + app_name: str + agent_uri: str + + +def _create_agui_agent() -> AgUiAgent: + """Lazy factory so each worker process gets its own agent instance.""" + from agent.agent import create_plan_agent + + return AgUiAgent(trpc_agent=create_plan_agent(), app_name=APP_NAME) + + +@asynccontextmanager +async def _lifespan(app: FastAPI): # noqa: ARG001 + if not os.environ.get("TRPC_AGENT_API_KEY"): + logger.warning( + "TRPC_AGENT_API_KEY is not set — copy .env.example values into %s/.env", + _EXAMPLE_DIR, + ) + if not INDEX_HTML.is_file(): + raise FileNotFoundError(f"Demo page not found: {INDEX_HTML}") + logger.info("Plan Mode AG-UI demo starting up.") + yield + logger.info("Plan Mode AG-UI demo shutting down.") + await agui_manager.close() + + +def create_app() -> FastAPI: + """Build the FastAPI app: AG-UI agent endpoint + static demo page.""" + app = FastAPI(title="Plan Mode AG-UI Demo", lifespan=_lifespan) + + @app.get("/", response_class=FileResponse) + async def index() -> FileResponse: + return FileResponse(INDEX_HTML) + + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: + return HealthResponse(app_name=APP_NAME, agent_uri=AGENT_URI) + + service = AgUiService(APP_NAME, app=app) + service.add_agent(AGENT_URI, _create_agui_agent) + agui_manager.register_service(APP_NAME, service) + agui_manager.set_app(app) + return app + + +app = create_app() + + +def serve(host: str = HOST, port: int = PORT) -> None: + """Start the FastAPI + AG-UI server.""" + print(f"Plan Mode AG-UI demo: http://{host}:{port}/") + print(f"Agent endpoint: http://{host}:{port}{AGENT_URI}") + print(f"Health check: http://{host}:{port}/health") + agui_manager.run(host, port) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plan Mode AG-UI browser demo") + parser.add_argument("--host", default=HOST, help=f"bind address (default: {HOST})") + parser.add_argument("--port", type=int, default=PORT, help=f"listen port (default: {PORT})") + return parser.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + serve(host=args.host, port=args.port) diff --git a/examples/plan_mode/static/index.html b/examples/plan_mode/static/index.html new file mode 100644 index 00000000..bc7119b4 --- /dev/null +++ b/examples/plan_mode/static/index.html @@ -0,0 +1,777 @@ + + + + + +Plan Mode · AG-UI Demo + + + + +
+

Plan Mode / AG-UI Demo

+
+ thread: - + +
+
+ +
+
+
对话
+
+
+
+ + +
+ + +
+
+ +
+
计划 & 待办
+
还没有计划,先发一条消息让 agent 进入 Plan Mode 吧。
+
+
+ + + + diff --git a/examples/plan_mode_with_goal_and_task/.env b/examples/plan_mode_with_goal_and_task/.env new file mode 100644 index 00000000..dc791393 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/plan_mode_with_goal_and_task/README.md b/examples/plan_mode_with_goal_and_task/README.md new file mode 100644 index 00000000..78a3794e --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/README.md @@ -0,0 +1,97 @@ +# Plan Mode + Goal + Task 示例 + +演示 **Plan Mode**、**Goal**、**Task** 三种能力的组合使用:先规划并人工审批,再用 Goal 锁定会话完成契约,用 Task 看板跟踪实施步骤。 + +## 它解决什么问题 + +| 能力 | 职责 | +| --- | --- | +| **Plan Mode** | 调研 → 起草计划 → 人工审批 → 解锁写工具 | +| **Goal** | 会话级持久目标;active 期间不允许过早收尾 | +| **Task** | 批准后按 id 增量更新任务看板,支持依赖编排 | + +三者分工:Plan 管「设计文档 + 写操作 gate」,Task 管步骤分解,Goal 管「整件事是否做完」。 + +## 目录结构 + +``` +plan_mode_with_goal_and_task/ +├── agent/ +│ ├── agent.py # orchestrator:FileToolSet + SpawnSubAgentTool + TaskToolSet + setup_plan + setup_goal +│ ├── prompts.py # 组合 workflow 的 system instruction +│ └── __init__.py +├── static/ +│ └── index.html # AG-UI 浏览器 Demo(计划 + 目标 + 任务三栏侧板) +├── run_agent_with_agui.py +├── .env +└── README.md +``` + +## 架构 + +``` +orchestrator (LlmAgent) +├── FileToolSet # Read/Grep/Glob 始终可用;Write/Edit/Bash 计划批准后解锁 +├── SpawnSubAgentTool # Explore / Plan 只读子 agent +├── TaskToolSet # task_create / task_update / task_get / task_list(gate 期间被拦截) +├── PlanToolSet (setup_plan) # enter_plan_mode / update_plan_content / exit_plan_mode / ask_user_question +└── GoalToolSet (setup_goal) # create_goal / get_goal / update_goal + enforcement callbacks +``` + +典型流程: + +1. 用户描述多步需求(或 UI 切换 Plan 模式) +2. 进入 Plan Mode → 只读调研 → 撰写计划 → `exit_plan_mode` 等待审批 +3. 审批通过后 → `create_goal` 设定会话目标 → `task_create` 分解实施步骤 +4. 逐步执行 Write / Bash,`task_update` 更新进度 +5. 全部完成后 `update_goal(complete)` + +> Plan gate 激活期间,`task_create` / `task_update` / `create_goal` / `update_goal` 会被 `PLAN_MODE_GATE` 拦截(见 `DEFAULT_WRITE_TOOL_NAMES`)。 + +## 前置条件 + +```bash +cd trpc-agent-python +python3 -m venv .venv && source .venv/bin/activate +pip3 install -e '.[ag-ui]' + +# 配置 examples/plan_mode_with_goal_and_task/.env +TRPC_AGENT_API_KEY=<你的 key> +TRPC_AGENT_BASE_URL=<可选> +TRPC_AGENT_MODEL_NAME=<可选,默认 gpt-4.1-mini> +``` + +## 运行 + +```bash +cd examples/plan_mode_with_goal_and_task +python3 run_agent_with_agui.py +``` + +启动后打开 (默认端口 **18091**,与 `plan_mode` 示例的 18090 区分)。 + +## 浏览器 Demo 说明 + +侧板实时展示三块 session state(均来自 AG-UI `STATE_SNAPSHOT`): + +| 面板 | state key | 说明 | +| --- | --- | --- | +| 当前计划 | `plan:orchestrator` | 计划状态、目标、正文、澄清问答 | +| 会话目标 | `goal:orchestrator` | Goal 的 objective / status | +| 任务看板 | `tasks:orchestrator` | TaskStore(id、subject、status、blockedBy) | + +HITL 交互与 [plan_mode](../plan_mode/) 相同:`enter_plan_mode` / `exit_plan_mode` / `ask_user_question` 卡片 + UI Plan 模式自动进入。 + +建议试用 prompt: + +``` +帮我参考 QQ 音乐生成一个类似的前端项目 +``` + +切换 **Plan** 模式后发送,可跳过 `enter_plan_mode` 确认,直接进入规划。 + +## 相关文档 + +- [Plan Mode](../../docs/mkdocs/zh/plan.md) +- [Goal 工具](../../docs/mkdocs/zh/goal.md) +- [Task 工具族](../../docs/mkdocs/zh/tool_task.md) diff --git a/examples/plan_mode_with_goal_and_task/agent/__init__.py b/examples/plan_mode_with_goal_and_task/agent/__init__.py new file mode 100644 index 00000000..513ac2da --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/agent/__init__.py @@ -0,0 +1,9 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from .agent import create_plan_goal_task_agent + +__all__ = ["create_plan_goal_task_agent"] diff --git a/examples/plan_mode_with_goal_and_task/agent/agent.py b/examples/plan_mode_with_goal_and_task/agent/agent.py new file mode 100644 index 00000000..29fe0f89 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/agent/agent.py @@ -0,0 +1,46 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import os + +from dotenv import load_dotenv +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.plan_mode import setup_plan +from trpc_agent_sdk.tools import FileToolSet +from trpc_agent_sdk.tools import SpawnSubAgentTool +from trpc_agent_sdk.tools import TaskToolSet +from trpc_agent_sdk.tools.goal_tools import GoalOptions +from trpc_agent_sdk.tools.goal_tools import setup_goal + +from .prompts import SYSTEM_INSTRUCTION + +load_dotenv() + + +def create_plan_goal_task_agent() -> LlmAgent: + model = OpenAIModel( + model_name=os.environ.get("TRPC_AGENT_MODEL_NAME", "gpt-4.1-mini"), + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url=os.environ.get("TRPC_AGENT_BASE_URL"), + ) + agent = LlmAgent( + name="orchestrator", + model=model, + instruction=SYSTEM_INSTRUCTION, + tools=[ + FileToolSet(), + SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT]), + TaskToolSet(), + ], + ) + setup_plan(agent) + setup_goal(agent, GoalOptions(max_retries=3)) + return agent diff --git a/examples/plan_mode_with_goal_and_task/agent/prompts.py b/examples/plan_mode_with_goal_and_task/agent/prompts.py new file mode 100644 index 00000000..2814b2f7 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/agent/prompts.py @@ -0,0 +1,7 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +SYSTEM_INSTRUCTION = """You are a coding assistant. Prefer reusing existing code and patterns in the workspace.""" diff --git a/examples/plan_mode_with_goal_and_task/run_agent_with_agui.py b/examples/plan_mode_with_goal_and_task/run_agent_with_agui.py new file mode 100644 index 00000000..59e18ba6 --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/run_agent_with_agui.py @@ -0,0 +1,127 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""AG-UI server for Plan Mode + Goal + Task combined example. + +Exposes the orchestrator (see agent/agent.py) over the AG-UI protocol and +serves a dependency-free static HTML page (static/index.html) from the same +FastAPI app. The page shows plan, session goal, and task board side panels. + +Run: + cd examples/plan_mode_with_goal_and_task + python3 run_agent_with_agui.py + +Then open http://127.0.0.1:18091/ in a browser. + +Prerequisites: + pip install -e '.[ag-ui]' + Set TRPC_AGENT_API_KEY (and optionally TRPC_AGENT_BASE_URL / TRPC_AGENT_MODEL_NAME) + in examples/plan_mode_with_goal_and_task/.env +""" + +from __future__ import annotations + +import argparse +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.server.ag_ui import AgUiAgent +from trpc_agent_sdk.server.ag_ui import AgUiManager +from trpc_agent_sdk.server.ag_ui import AgUiService + +_EXAMPLE_DIR = Path(__file__).resolve().parent +if str(_EXAMPLE_DIR) not in sys.path: + sys.path.insert(0, str(_EXAMPLE_DIR)) + +load_dotenv(_EXAMPLE_DIR / ".env") + +HOST = "127.0.0.1" +PORT = 18093 +APP_NAME = "plan_mode_goal_task_agui_demo" +AGENT_URI = "/plan_agent" +STATIC_DIR = _EXAMPLE_DIR / "static" +INDEX_HTML = STATIC_DIR / "index.html" + +agui_manager = AgUiManager() + + +class HealthResponse(BaseModel): + """Response body for GET /health.""" + + status: str = "ok" + app_name: str + agent_uri: str + + +def _create_agui_agent() -> AgUiAgent: + """Lazy factory so each worker process gets its own agent instance.""" + from agent.agent import create_plan_goal_task_agent + + return AgUiAgent(trpc_agent=create_plan_goal_task_agent(), app_name=APP_NAME) + + +@asynccontextmanager +async def _lifespan(app: FastAPI): # noqa: ARG001 + if not os.environ.get("TRPC_AGENT_API_KEY"): + logger.warning( + "TRPC_AGENT_API_KEY is not set — copy .env values into %s/.env", + _EXAMPLE_DIR, + ) + if not INDEX_HTML.is_file(): + raise FileNotFoundError(f"Demo page not found: {INDEX_HTML}") + logger.info("Plan + Goal + Task AG-UI demo starting up.") + yield + logger.info("Plan + Goal + Task AG-UI demo shutting down.") + await agui_manager.close() + + +def create_app() -> FastAPI: + """Build the FastAPI app: AG-UI agent endpoint + static demo page.""" + app = FastAPI(title="Plan + Goal + Task AG-UI Demo", lifespan=_lifespan) + + @app.get("/", response_class=FileResponse) + async def index() -> FileResponse: + return FileResponse(INDEX_HTML) + + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: + return HealthResponse(app_name=APP_NAME, agent_uri=AGENT_URI) + + service = AgUiService(APP_NAME, app=app) + service.add_agent(AGENT_URI, _create_agui_agent) + agui_manager.register_service(APP_NAME, service) + agui_manager.set_app(app) + return app + + +app = create_app() + + +def serve(host: str = HOST, port: int = PORT) -> None: + """Start the FastAPI + AG-UI server.""" + print(f"Plan + Goal + Task AG-UI demo: http://{host}:{port}/") + print(f"Agent endpoint: http://{host}:{port}{AGENT_URI}") + print(f"Health check: http://{host}:{port}/health") + agui_manager.run(host, port) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plan + Goal + Task AG-UI browser demo") + parser.add_argument("--host", default=HOST, help=f"bind address (default: {HOST})") + parser.add_argument("--port", type=int, default=PORT, help=f"listen port (default: {PORT})") + return parser.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + serve(host=args.host, port=args.port) diff --git a/examples/plan_mode_with_goal_and_task/static/index.html b/examples/plan_mode_with_goal_and_task/static/index.html new file mode 100644 index 00000000..df3832aa --- /dev/null +++ b/examples/plan_mode_with_goal_and_task/static/index.html @@ -0,0 +1,903 @@ + + + + + +Plan + Goal + Task · AG-UI Demo + + + + +
+

Plan + Goal + Task / AG-UI Demo

+
+ thread: - + +
+
+ +
+
+
对话
+
+
+
+ + +
+ + +
+
+ +
+
计划 · 目标 · 任务
+
还没有状态。先发一条消息让 agent 进入 Plan Mode,审批通过后会创建 Goal 与 Task 看板。
+
+
+ + + + diff --git a/mkdocs.yml b/mkdocs.yml index ae69b587..f6a46830 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,9 @@ nav: - Model: en/model.md - Tools: - Tool: en/tool.md + - TodoWrite Tool: en/tool_todowrite.md + - Task Tools: en/tool_task.md + - Goal: en/goal.md - Code Executor: en/code_executor.md - Skills: en/skill.md - Session & Memory: @@ -60,6 +63,7 @@ nav: - Runtime: - Filter: en/filter.md - Human in the Loop: en/human_in_the_loop.md + - Plan Mode: en/plan.md - Cancellation: en/cancel.md - 中文: - 概览: zh/index.md @@ -75,6 +79,9 @@ nav: - 模型: zh/model.md - 工具: - 工具: zh/tool.md + - TodoWrite 工具: zh/tool_todowrite.md + - Task 工具: zh/tool_task.md + - Goal 工具: zh/goal.md - 代码执行器: zh/code_executor.md - Skills: zh/skill.md - Session 与 Memory: @@ -102,4 +109,5 @@ nav: - 运行时: - Filter: zh/filter.md - Human in the Loop: zh/human_in_the_loop.md + - Plan Mode: zh/plan.md - Cancellation: zh/cancel.md diff --git a/tests/plan_mode/__init__.py b/tests/plan_mode/__init__.py new file mode 100644 index 00000000..898f538c --- /dev/null +++ b/tests/plan_mode/__init__.py @@ -0,0 +1,5 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. diff --git a/tests/plan_mode/test_plan_mode.py b/tests/plan_mode/test_plan_mode.py new file mode 100644 index 00000000..1851e552 --- /dev/null +++ b/tests/plan_mode/test_plan_mode.py @@ -0,0 +1,962 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import time +import uuid + +import pytest + +from trpc_agent_sdk.plan_mode import PlanStatus +from trpc_agent_sdk.plan_mode import decode_plan +from trpc_agent_sdk.plan_mode import encode_plan +from trpc_agent_sdk.plan_mode import get_plan_record +from trpc_agent_sdk.plan_mode import plan_to_task_subjects +from trpc_agent_sdk.plan_mode._lock import reset_locks_for_tests +from trpc_agent_sdk.plan_mode._models import PlanRecord +from trpc_agent_sdk.plan_mode._store import apply_approval_decision +from trpc_agent_sdk.plan_mode._store import apply_enter +from trpc_agent_sdk.plan_mode._store import apply_enter_decision +from trpc_agent_sdk.plan_mode._store import apply_request_enter +from trpc_agent_sdk.plan_mode._store import apply_request_exit +from trpc_agent_sdk.plan_mode._store import apply_update_content +from trpc_agent_sdk.sessions import InMemorySessionService + + +async def _enter_plan_mode_confirmed(ctx, objective: str = "x") -> None: + from trpc_agent_sdk.plan_mode._long_running_tools import make_enter_plan_mode_tool + from trpc_agent_sdk.plan_mode._long_running_tools import process_hitl_function_response + + tool = make_enter_plan_mode_tool() + pending = await tool._run_async_impl(tool_context=ctx, args={"objective": objective}) + assert pending["status"] == "pending_enter" + process_hitl_function_response( + ctx, + name="enter_plan_mode", + response={ + "status": "approved", + "reviewer_note": "ok", + }, + ) + + +class _FakeSession: + + def __init__(self, state: dict | None = None): + self.state = state or {} + + +@pytest.fixture(autouse=True) +def _reset_plan_locks(): + reset_locks_for_tests() + yield + reset_locks_for_tests() + + +def test_encode_decode_roundtrip(): + record = PlanRecord( + id="abc", + status=PlanStatus.DRAFTING, + objective="refactor auth", + content="## Step 1\nDo thing", + started_at_unix=1, + ) + raw = encode_plan(record) + decoded = decode_plan(raw) + assert decoded is not None + assert decoded.objective == "refactor auth" + assert decoded.status == PlanStatus.DRAFTING + + +def test_request_enter_approve_and_reject(): + now = int(time.time()) + record, err, payload = apply_request_enter(None, objective="build feature", request_id="req-enter", now_unix=now) + assert err is None + assert record.status == PlanStatus.PENDING_ENTER + assert payload["status"] == "pending_enter" + + record, err, result = apply_enter_decision( + record, + decision="approved", + reviewer_note="go", + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.EXPLORING + assert result["status"] == "approved" + + record, err, payload = apply_request_enter(None, objective="another", request_id="req-enter-2", now_unix=now) + assert err is None + _, err, result = apply_enter_decision( + record, + decision="rejected", + reviewer_note="not now", + now_unix=now, + ) + assert err is None + assert result["status"] == "rejected" + + +def test_state_machine_enter_update_exit_approve(): + now = int(time.time()) + record, err = apply_enter(None, objective="build feature", now_unix=now) + assert err is None + assert record.status == PlanStatus.EXPLORING + + record, err = apply_update_content( + record, + content="# Plan\n## Step A", + mode="replace", + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.DRAFTING + + record, err, payload = apply_request_exit( + record, + summary="ready", + request_id="req1", + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.PENDING_APPROVAL + assert payload["status"] == "pending_approval" + + record, err, result = apply_approval_decision( + record, + decision="approved", + reviewer_note="lgtm", + edited_content=None, + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.APPROVED + assert result["status"] == "approved" + + +def test_reject_returns_to_drafting(): + now = int(time.time()) + record, _ = apply_enter(None, objective="x", now_unix=now) + record, _ = apply_update_content(record, content="plan body", mode="replace", now_unix=now) + record, _, _ = apply_request_exit(record, summary="", request_id="r", now_unix=now) + record, err, result = apply_approval_decision( + record, + decision="rejected", + reviewer_note="need more detail", + edited_content=None, + now_unix=now, + ) + assert err is None + assert record.status == PlanStatus.DRAFTING + assert result["status"] == "rejected" + + +def test_plan_to_task_subjects(): + record = PlanRecord( + id="1", + status=PlanStatus.APPROVED, + objective="o", + content="## Setup\n\n## Implement\n\nplain", + started_at_unix=1, + ) + assert plan_to_task_subjects(record) == ["Setup", "Implement"] + + +@pytest.mark.asyncio +async def test_plan_tools_e2e_offline(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool, decode_plan, encode_plan, state_key + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._long_running_tools import make_exit_plan_mode_tool + from trpc_agent_sdk.plan_mode._long_running_tools import process_hitl_function_response + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s1") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + + await _enter_plan_mode_confirmed(ctx, objective="migrate auth") + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={ + "content": "## Step 1\nDo work", + "mode": "replace" + }, + ) + exit_tool = make_exit_plan_mode_tool() + pending = await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review"}) + assert pending["status"] == "pending_approval" + + process_hitl_function_response( + ctx, + name="exit_plan_mode", + response={ + "status": "approved", + "reviewer_note": "ok" + }, + ) + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.APPROVED + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + + class _WriteTool(BaseTool): + + def __init__(self): + super().__init__(name="Write", description="write") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + # Re-open gate for write-block test + plan.status = PlanStatus.DRAFTING + ctx.state[state_key("plan", agent.name)] = encode_plan(plan) + blocked = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + plan.status = PlanStatus.APPROVED + ctx.state[state_key("plan", agent.name)] = encode_plan(plan) + allowed = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert allowed is None + + +@pytest.mark.asyncio +async def test_edit_tool_is_blocked_by_default_denylist(): + """Regression test: the real file-editing tool is named ``Edit`` (not + ``edit_file``) — the denylist must match the actual registered name or + the plan gate silently lets file edits through.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_edit") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + + class _EditTool(BaseTool): + + def __init__(self): + super().__init__(name="Edit", description="edit") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + blocked = await callbacks.before_tool(ctx, _EditTool(), {}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + +@pytest.mark.asyncio +async def test_spawn_subagent_restricted_to_readonly_archetypes(): + """spawn_subagent must only bypass the gate for read-only archetypes; + other archetypes (e.g. "default") inherit the parent's full — possibly + write-capable — tool surface and must stay gated.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_spawn") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + + class _SpawnTool(BaseTool): + + def __init__(self): + super().__init__(name="spawn_subagent", description="spawn") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + tool = _SpawnTool() + + allowed = await callbacks.before_tool(ctx, tool, {"subagent_type": "Explore"}, {}) + assert allowed is None + + blocked = await callbacks.before_tool(ctx, tool, {"subagent_type": "default"}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + blocked_missing = await callbacks.before_tool(ctx, tool, {}, {}) + assert blocked_missing is not None and "PLAN_MODE_GATE" in blocked_missing["error"] + + +@pytest.mark.asyncio +async def test_dynamic_subagent_requires_explicit_readonly_tools(): + """dynamic_subagent must only bypass the gate when it explicitly narrows + itself to read-only tools; without that restriction it can inherit the + parent's full tool surface and must stay gated.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_dyn") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + + class _DynamicTool(BaseTool): + + def __init__(self): + super().__init__(name="dynamic_subagent", description="dynamic") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + tool = _DynamicTool() + + allowed = await callbacks.before_tool(ctx, tool, {"tools": ["Read", "Grep"]}, {}) + assert allowed is None + + blocked_write = await callbacks.before_tool(ctx, tool, {"tools": ["Read", "Write"]}, {}) + assert blocked_write is not None and "PLAN_MODE_GATE" in blocked_write["error"] + + blocked_unrestricted = await callbacks.before_tool(ctx, tool, {}, {}) + assert blocked_unrestricted is not None and "PLAN_MODE_GATE" in blocked_unrestricted["error"] + + +@pytest.mark.asyncio +async def test_hitl_response_replaced_with_standardized_payload(): + """The model must see the state machine's standardized resume payload, + not the raw (possibly minimal) decision the host application sent.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._long_running_tools import make_exit_plan_mode_tool + from trpc_agent_sdk.types import Content, FunctionResponse, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_hitl") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={ + "content": "## Step 1", + "mode": "replace" + }, + ) + exit_tool = make_exit_plan_mode_tool() + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review"}) + + # Host resumes with a minimal, non-standard payload. + fr = FunctionResponse(name="exit_plan_mode", response={"status": "approved"}) + request = LlmRequest(contents=[Content(role="user", parts=[Part(function_response=fr)])]) + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + await callbacks.before_model(ctx, request) + + resumed_fr = request.contents[0].parts[0].function_response + assert resumed_fr.response["status"] == "approved" + assert "message" in resumed_fr.response + assert "plan" in resumed_fr.response + + +@pytest.mark.asyncio +async def test_lock_registry_released_on_approval(): + """The per-session lock registry must not grow without bound: approving + a plan should drop its entry from ``_locks``.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool + from trpc_agent_sdk.plan_mode._lock import _locks, store_lock_key + from trpc_agent_sdk.plan_mode._long_running_tools import (make_exit_plan_mode_tool, process_hitl_function_response) + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_lock_approve") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx) + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={ + "content": "## Step 1", + "mode": "replace" + }, + ) + exit_tool = make_exit_plan_mode_tool() + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review"}) + key = store_lock_key(ctx, prefix="plan", branch="orch") + assert key in _locks + process_hitl_function_response(ctx, name="exit_plan_mode", response={"status": "approved"}) + assert key not in _locks + + +@pytest.mark.asyncio +async def test_before_model_injects_awareness_without_active_plan(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._prompt import DEFAULT_PLAN_AWARENESS_PROMPT + from trpc_agent_sdk.plan_mode._prompt import _PLAN_AWARENESS_MARKER + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_aware") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + request = LlmRequest() + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt=DEFAULT_PLAN_AWARENESS_PROMPT, + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + + await callbacks.before_model(ctx, request) + + instructions = str(request.config.system_instruction) + assert _PLAN_AWARENESS_MARKER in instructions + assert "ACTIVE PLAN PROMPT" not in instructions + + +@pytest.mark.asyncio +async def test_before_model_injects_active_plan_prompt_when_gate_active(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._prompt import DEFAULT_PLAN_AWARENESS_PROMPT + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_active") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx, objective="build feature") + + request = LlmRequest() + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt=DEFAULT_PLAN_AWARENESS_PROMPT, + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + + await callbacks.before_model(ctx, request) + + instructions = str(request.config.system_instruction) + assert "ACTIVE PLAN PROMPT" in instructions + assert DEFAULT_PLAN_AWARENESS_PROMPT not in instructions + + +@pytest.mark.asyncio +async def test_ask_user_question_hitl_applies_answer_and_is_idempotent(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode import decode_plan, state_key + from trpc_agent_sdk.plan_mode._long_running_tools import ( + make_ask_user_question_tool, + process_hitl_function_response, + ) + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_ask") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx, objective="build app") + ask_tool = make_ask_user_question_tool() + pending = await ask_tool._run_async_impl( + tool_context=ctx, + args={"question": "Which framework?", "options": ["React", "Vue"]}, + ) + assert pending["status"] == "pending_question" + assert pending["question_id"] == 1 + + result = process_hitl_function_response( + ctx, + name="ask_user_question", + response={"answer": "React", "question_id": 1}, + ) + assert result is not None + assert result["status"] == "answered" + + plan = decode_plan(ctx.state.get(state_key("plan", "orch"))) + assert plan is not None + assert plan.asked_questions[0].answer == "React" + + # Re-processing the standardized payload must not corrupt state. + assert process_hitl_function_response( + ctx, + name="ask_user_question", + response=result, + ) is None + + # pending_question payloads from the initial LRO pause are ignored on resume. + assert process_hitl_function_response( + ctx, + name="ask_user_question", + response=pending, + ) is None + + +@pytest.mark.asyncio +async def test_reject_revise_reapprove_does_not_replay_old_rejection(): + """Regression: replaying an older exit_plan_mode rejection from conversation + history must not roll a newer pending submission back to drafting.""" + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode import UpdatePlanContentTool, decode_plan, state_key + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._long_running_tools import make_exit_plan_mode_tool + from trpc_agent_sdk.tools._base_tool import BaseTool + from trpc_agent_sdk.types import Content, FunctionResponse, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + class _WriteTool(BaseTool): + + def __init__(self): + super().__init__(name="Write", description="write") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_reapprove") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + await _enter_plan_mode_confirmed(ctx, objective="build player") + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={"content": "## v1", "mode": "replace"}, + ) + exit_tool = make_exit_plan_mode_tool() + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review v1"}) + + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="", + awareness_prompt="", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=False, + inject_awareness=False, + on_approval=None, + ) + + reject_request = LlmRequest(contents=[ + Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "rejected", "reviewer_note": "need more pages"}, + ))], + ), + ]) + await callbacks.before_model(ctx, reject_request) + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.DRAFTING + + await UpdatePlanContentTool()._run_async_impl( + tool_context=ctx, + args={"content": "## v2 full", "mode": "replace"}, + ) + await exit_tool._run_async_impl(tool_context=ctx, args={"summary": "review v2"}) + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.PENDING_APPROVAL + + approve_request = LlmRequest(contents=[ + Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "rejected", "reviewer_note": "need more pages"}, + ))], + ), + Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="exit_plan_mode", + response={"status": "approved", "reviewer_note": "looks good"}, + ))], + ), + ]) + await callbacks.before_model(ctx, approve_request) + + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.APPROVED + + blocked = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert blocked is None + + +@pytest.mark.asyncio +async def test_force_enter_plan_via_session_state_signal(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode import decode_plan, state_key + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.tools._base_tool import BaseTool + from trpc_agent_sdk.types import Content, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + class _WriteTool(BaseTool): + + def __init__(self): + super().__init__(name="Write", description="write") + + def _get_declaration(self): + return None + + async def _run_async_impl(self, *, tool_context, args): + return {"ok": True} + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_ui_plan") + session.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + user_content=Content(role="user", parts=[Part(text="Build a dashboard")]), + ) + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt="awareness", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + + request = LlmRequest() + await callbacks.before_model(ctx, request) + + plan = decode_plan(ctx.state.get(state_key("plan", agent.name))) + assert plan is not None + assert plan.status == PlanStatus.EXPLORING + assert plan.objective == "Build a dashboard" + assert "ACTIVE PLAN PROMPT" in str(request.config.system_instruction) + + blocked = await callbacks.before_tool(ctx, _WriteTool(), {}, {}) + assert blocked is not None and "PLAN_MODE_GATE" in blocked["error"] + + +@pytest.mark.asyncio +async def test_force_enter_blocks_enter_plan_mode_tool(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.models import LlmRequest + from trpc_agent_sdk.plan_mode._controller import _PlanCallbacks + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_WRITE_TOOL_NAMES + from trpc_agent_sdk.plan_mode._long_running_tools import make_enter_plan_mode_tool + from trpc_agent_sdk.types import Content, Part + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_block_enter") + session.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + user_content=Content(role="user", parts=[Part(text="Build a player")]), + ) + callbacks = _PlanCallbacks( + state_key_prefix="plan", + plan_prompt="ACTIVE PLAN PROMPT", + awareness_prompt="awareness", + write_tool_names=DEFAULT_WRITE_TOOL_NAMES, + inject_prompt=True, + inject_awareness=True, + on_approval=None, + ) + await callbacks.before_model(ctx, LlmRequest()) + + enter_tool = make_enter_plan_mode_tool() + blocked = await callbacks.before_tool(ctx, enter_tool, {"objective": "x"}, {}) + assert blocked is not None + assert "enter_plan_mode" in blocked["error"] + assert "UI" in blocked["error"] or "automatic" in blocked["error"] + + +@pytest.mark.asyncio +async def test_plan_toolset_hides_enter_plan_mode_when_ui_plan_selected(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.plan_mode._plan_toolset import PlanToolSet + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_hide_enter") + session.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + + tool_names = [tool.name for tool in await PlanToolSet().get_tools(ctx)] + assert "enter_plan_mode" not in tool_names + assert "update_plan_content" in tool_names + assert "exit_plan_mode" in tool_names + assert "ask_user_question" in tool_names + + +@pytest.mark.asyncio +async def test_plan_toolset_keeps_enter_plan_mode_in_agent_mode(): + from trpc_agent_sdk.agents._base_agent import BaseAgent + from trpc_agent_sdk.context import InvocationContext, create_agent_context + from trpc_agent_sdk.plan_mode._plan_toolset import PlanToolSet + + class _Stub(BaseAgent): + + async def _run_async_impl(self, ctx): + yield + + svc = InMemorySessionService() + session = await svc.create_session(app_name="t", user_id="u", session_id="s_show_enter") + agent = _Stub(name="orch") + ctx = InvocationContext( + session_service=svc, + invocation_id="inv", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + + tool_names = [tool.name for tool in await PlanToolSet().get_tools(ctx)] + assert "enter_plan_mode" in tool_names + diff --git a/tests/server/ag_ui/_core/test_agui_agent.py b/tests/server/ag_ui/_core/test_agui_agent.py index d837cc91..08bb62eb 100644 --- a/tests/server/ag_ui/_core/test_agui_agent.py +++ b/tests/server/ag_ui/_core/test_agui_agent.py @@ -402,6 +402,114 @@ def test_mixed_tools(self, agui_agent): assert "proxy_tool" in result assert len(result) == 2 + @pytest.mark.asyncio + async def test_expands_nested_toolset_long_running_tools(self, agui_agent): + from trpc_agent_sdk.plan_mode import PlanToolSet + + agent = Mock(spec=BaseAgent) + agent.tools = [PlanToolSet()] + + result = await agui_agent._extract_long_running_tool_names_async(agent) + + assert "exit_plan_mode" in result + assert "ask_user_question" in result + + +# --------------------------------------------------------------------------- +# TestResolveToolNameFromSession +# --------------------------------------------------------------------------- + + +class TestResolveToolNameFromSession: + @pytest.mark.asyncio + async def test_resolves_tool_name_from_session_events(self, agui_agent): + from trpc_agent_sdk import types + from trpc_agent_sdk.events import Event + + session = await agui_agent._session_manager._session_service.create_session( + app_name="test_app", + user_id="test_user", + session_id="thread-1", + ) + call_event = Event( + invocation_id="inv-1", + author="orch", + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + id="call_abc", + name="ask_user_question", + args={"question": "Pick one"}, + )) + ], + ), + ) + await agui_agent._session_manager._session_service.append_event(session=session, event=call_event) + + resolved = await agui_agent._resolve_tool_name_from_session( + thread_id="thread-1", + app_name="test_app", + user_id="test_user", + tool_call_id="call_abc", + ) + assert resolved == "ask_user_question" + + @pytest.mark.asyncio + async def test_extract_tool_results_falls_back_to_session(self, agui_agent): + from ag_ui.core import ToolMessage + from trpc_agent_sdk import types + from trpc_agent_sdk.events import Event + + session = await agui_agent._session_manager._session_service.create_session( + app_name="test_app", + user_id="test_user", + session_id="thread-2", + ) + call_event = Event( + invocation_id="inv-2", + author="orch", + content=types.Content( + role="model", + parts=[ + types.Part( + function_call=types.FunctionCall( + id="call_xyz", + name="ask_user_question", + args={"question": "Pick one"}, + )) + ], + ), + ) + await agui_agent._session_manager._session_service.append_event(session=session, event=call_event) + + tool_msg = ToolMessage( + id="msg-tool", + role="tool", + tool_call_id="call_xyz", + content='{"answer": "React", "question_id": 1}', + ) + inp = RunAgentInput( + thread_id="thread-2", + run_id="run-1", + state={}, + messages=[tool_msg], + tools=[], + context=[], + forwarded_props={}, + ) + + results = await agui_agent._extract_tool_results( + inp, + thread_id="thread-2", + app_name="test_app", + user_id="test_user", + ) + + assert len(results) == 1 + assert results[0]["tool_name"] == "ask_user_question" + # --------------------------------------------------------------------------- # TestDefaultRunConfig diff --git a/tests/server/ag_ui/_core/test_session_manager.py b/tests/server/ag_ui/_core/test_session_manager.py index b064a2b5..c6cc61e5 100644 --- a/tests/server/ag_ui/_core/test_session_manager.py +++ b/tests/server/ag_ui/_core/test_session_manager.py @@ -186,6 +186,35 @@ async def test_success(self): assert result is True svc.append_event.assert_called_once() + appended_event = svc.append_event.call_args[0][1] + assert appended_event.partial is False + + async def test_persists_session_state_with_in_memory_service(self): + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + from trpc_agent_sdk.plan_mode._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + from trpc_agent_sdk.sessions import InMemorySessionService + + svc = InMemorySessionService() + try: + await svc.create_session( + app_name="app", + user_id="u1", + session_id="s1", + state={DEFAULT_FORCE_ENTER_PLAN_STATE_KEY: "agent"}, + ) + mgr = SessionManager(session_service=svc, auto_cleanup=False) + result = await mgr.update_session_state( + "s1", + "app", + "u1", + {DEFAULT_FORCE_ENTER_PLAN_STATE_KEY: DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE}, + ) + assert result is True + + stored = await svc.get_session(app_name="app", user_id="u1", session_id="s1") + assert stored.state[DEFAULT_FORCE_ENTER_PLAN_STATE_KEY] == DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + finally: + await svc.close() async def test_session_not_found(self): svc = _make_session_service() diff --git a/trpc_agent_sdk/plan_mode/__init__.py b/trpc_agent_sdk/plan_mode/__init__.py new file mode 100644 index 00000000..00950dbd --- /dev/null +++ b/trpc_agent_sdk/plan_mode/__init__.py @@ -0,0 +1,61 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Plan Mode — session-scoped design + approval gate before implementation. + +Mount with :func:`setup_plan` on a :class:`trpc_agent_sdk.agents.LlmAgent`. +Pair with :class:`trpc_agent_sdk.tools.SpawnSubAgentTool` and built-in +``EXPLORE_AGENT`` / ``PLAN_AGENT`` archetypes for read-only exploration and design. +""" + +from ._controller import ApprovalEvent +from ._long_running_tools import make_enter_plan_mode_tool +from ._helpers import DEFAULT_READONLY_SUBAGENT_TYPES +from ._helpers import DEFAULT_READONLY_TOOL_NAMES +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import DEFAULT_WRITE_TOOL_NAMES +from ._helpers import PLAN_TOOL_NAMES +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import get_plan_record +from ._helpers import plan_to_task_subjects +from ._helpers import render_plan +from ._helpers import state_key +from ._models import PlanQuestion +from ._models import PlanRecord +from ._models import PlanStatus +from ._plan_toolset import PlanToolSet +from ._prompt import DEFAULT_PLAN_AWARENESS_PROMPT +from ._prompt import DEFAULT_PLAN_MODE_PROMPT +from ._setup import OnApproval +from ._setup import PlanOptions +from ._setup import setup_plan +from ._update_plan_content_tool import UpdatePlanContentTool + +__all__ = [ + "ApprovalEvent", + "make_enter_plan_mode_tool", + "OnApproval", + "PlanOptions", + "PlanQuestion", + "PlanRecord", + "PlanStatus", + "PlanToolSet", + "UpdatePlanContentTool", + "DEFAULT_PLAN_AWARENESS_PROMPT", + "DEFAULT_PLAN_MODE_PROMPT", + "DEFAULT_READONLY_SUBAGENT_TYPES", + "DEFAULT_READONLY_TOOL_NAMES", + "DEFAULT_STATE_KEY_PREFIX", + "DEFAULT_WRITE_TOOL_NAMES", + "PLAN_TOOL_NAMES", + "decode_plan", + "encode_plan", + "get_plan_record", + "plan_to_task_subjects", + "render_plan", + "setup_plan", + "state_key", +] diff --git a/trpc_agent_sdk/plan_mode/_base.py b/trpc_agent_sdk/plan_mode/_base.py new file mode 100644 index 00000000..a36a0a4b --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_base.py @@ -0,0 +1,70 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Shared base for Plan Mode tools.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.tools._base_tool import BaseTool + +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import state_key +from ._lock import plan_store_lock +from ._models import PlanRecord + + +class _PlanToolBase(BaseTool): + """Branch-scoped plan load / save shared by all plan tools.""" + + def __init__( + self, + *, + name: str, + description: str, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, + filters_name: Optional[list[str]] = None, + filters: Optional[list[BaseFilter]] = None, + ) -> None: + super().__init__( + name=name, + description=description, + filters_name=filters_name, + filters=filters, + ) + self._prefix = state_key_prefix or DEFAULT_STATE_KEY_PREFIX + + def _resolve_branch(self, tool_context: InvocationContext) -> str: + return tool_context.branch or tool_context.agent_name or "" + + def _state_key(self, tool_context: InvocationContext) -> str: + return state_key(self._prefix, self._resolve_branch(tool_context)) + + def _load_plan(self, tool_context: InvocationContext) -> Optional[PlanRecord]: + return decode_plan(tool_context.state.get(self._state_key(tool_context))) + + def _save_plan(self, tool_context: InvocationContext, plan: PlanRecord) -> None: + branch = self._resolve_branch(tool_context) + if branch: + plan.branch = branch + tool_context.state[self._state_key(tool_context)] = encode_plan(plan) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + branch = self._resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=self._prefix, branch=branch): + return await self._run_plan(tool_context=tool_context, args=args) + + @abstractmethod + async def _run_plan(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + """Tool logic under plan_store_lock.""" diff --git a/trpc_agent_sdk/plan_mode/_controller.py b/trpc_agent_sdk/plan_mode/_controller.py new file mode 100644 index 00000000..fd2ab7e4 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_controller.py @@ -0,0 +1,296 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""PlanController — prompt injection, write gate, HITL resume handling.""" + +from __future__ import annotations + +import inspect +import time +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import FrozenSet +from typing import List +from typing import Optional + +from pydantic import BaseModel + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.tools._base_tool import BaseTool +from trpc_agent_sdk.types import FunctionResponse + +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE +from ._helpers import DEFAULT_READONLY_SUBAGENT_TYPES +from ._helpers import DEFAULT_READONLY_TOOL_NAMES +from ._helpers import PLAN_TOOL_NAMES +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import state_key +from ._long_running_tools import process_hitl_function_response +from ._models import PlanRecord +from ._models import PlanStatus +from ._prompt import _PLAN_AWARENESS_MARKER +from ._prompt import _PLAN_PROMPT_MARKER +from ._store import apply_enter + +if TYPE_CHECKING: + pass + + +class ApprovalEvent(BaseModel): + """Observability payload when a plan is approved or rejected.""" + + decision: str + agent_name: str + plan: PlanRecord + + +class _PlanCallbacks: + """Model / tool callbacks for Plan Mode enforcement.""" + + def __init__( + self, + *, + state_key_prefix: str, + plan_prompt: str, + awareness_prompt: str, + write_tool_names: FrozenSet[str], + inject_prompt: bool, + inject_awareness: bool, + on_approval: Optional[Callable[[ApprovalEvent], None]], + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY, + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE, + readonly_subagent_types: FrozenSet[str] = DEFAULT_READONLY_SUBAGENT_TYPES, + readonly_tool_names: FrozenSet[str] = DEFAULT_READONLY_TOOL_NAMES, + ) -> None: + self._prefix = state_key_prefix + self._plan_prompt = plan_prompt + self._awareness_prompt = awareness_prompt + self._write_tool_names = write_tool_names + self._inject_prompt = inject_prompt + self._inject_awareness = inject_awareness + self._force_enter_plan_state_key = force_enter_plan_state_key + self._force_enter_plan_state_value = force_enter_plan_state_value + self._on_approval = on_approval + self._readonly_subagent_types = readonly_subagent_types + self._readonly_tool_names = readonly_tool_names + + def _resolve_branch(self, ctx: InvocationContext) -> str: + return ctx.branch or ctx.agent_name or "" + + def _state_key(self, ctx: InvocationContext) -> str: + return state_key(self._prefix, self._resolve_branch(ctx)) + + def _load_plan(self, ctx: InvocationContext) -> Optional[PlanRecord]: + return decode_plan(ctx.state.get(self._state_key(ctx))) + + def _save_plan(self, ctx: InvocationContext, plan: PlanRecord) -> None: + branch = self._resolve_branch(ctx) + if branch: + plan.branch = branch + ctx.state[self._state_key(ctx)] = encode_plan(plan) + + @staticmethod + def _is_hitl_resume(ctx: InvocationContext) -> bool: + if ctx.user_content is None or not ctx.user_content.parts: + return False + return any(part.function_response is not None for part in ctx.user_content.parts) + + @staticmethod + def _objective_from_context(ctx: InvocationContext) -> str: + if ctx.user_content and ctx.user_content.parts: + texts = [part.text.strip() for part in ctx.user_content.parts if part.text and part.text.strip()] + if texts: + return "\n".join(texts)[:500] + return "Current task" + + def _should_force_enter(self, ctx: InvocationContext) -> bool: + """True when a session-state signal requests auto-enter.""" + if not self._force_enter_plan_state_key: + return False + return ctx.state.get(self._force_enter_plan_state_key) == self._force_enter_plan_state_value + + def _ensure_forced_plan(self, ctx: InvocationContext) -> None: + """Auto-enter Plan Mode when the UI/session signal is active.""" + if not self._should_force_enter(ctx) or self._is_hitl_resume(ctx): + return + existing = self._load_plan(ctx) + if existing is not None and existing.is_gate_active(): + return + record, error = apply_enter( + existing, + objective=self._objective_from_context(ctx), + now_unix=int(time.time()), + ) + if error: + logger.warning("auto-enter plan mode could not create plan: %s", error) + return + self._save_plan(ctx, record) + + @staticmethod + def _latest_user_function_responses(request: LlmRequest) -> List[FunctionResponse]: + """Function responses from the newest user turn only. + + ``before_model`` receives the full conversation, but HITL resume + payloads must be applied at most once and only for the current turn. + Re-playing an older ``exit_plan_mode`` rejection while a newer plan + submission is ``pending_approval`` would roll status back to + ``drafting`` and block implementation after a fresh approval. + """ + for content in reversed(request.contents or []): + if content.role != "user" or not content.parts: + continue + responses = [ + part.function_response for part in content.parts + if part.function_response is not None and isinstance(part.function_response.response, dict) + ] + if responses: + return responses + return [] + + def _process_hitl_in_request(self, ctx: InvocationContext, request: LlmRequest) -> None: + for fr in self._latest_user_function_responses(request): + result = process_hitl_function_response( + ctx, + name=fr.name, + response=fr.response, + state_key_prefix=self._prefix, + ) + if result is not None and not result.get("error"): + # Replace the host-supplied raw decision (e.g. just + # {"status": "approved"}) with the state machine's + # standardized payload (friendly message + full plan + # dump) so the model sees the same response it would + # get from a normal (non-HITL) tool call. + fr.response = result + if fr.name == "exit_plan_mode" and self._on_approval: + plan = self._load_plan(ctx) + if plan is not None: + try: + self._on_approval( + ApprovalEvent( + decision=str(fr.response.get("status", "")), + agent_name=ctx.agent_name, + plan=plan, + )) + except Exception as exc: # pylint: disable=broad-except + logger.warning("plan on_approval callback raised: %s", exc) + + def _existing_instructions(self, request: LlmRequest) -> str: + if request.config and request.config.system_instruction: + return str(request.config.system_instruction) + return "" + + async def before_model(self, ctx: InvocationContext, request: LlmRequest) -> Optional[LlmResponse]: + self._process_hitl_in_request(ctx, request) + self._ensure_forced_plan(ctx) + + plan = self._load_plan(ctx) + existing = self._existing_instructions(request) + + if plan is not None and plan.is_gate_active(): + if self._inject_prompt and _PLAN_PROMPT_MARKER not in existing: + request.append_instructions([self._plan_prompt]) + return None + + if (not self._should_force_enter(ctx) and self._inject_awareness and _PLAN_AWARENESS_MARKER not in existing): + request.append_instructions([self._awareness_prompt]) + return None + + async def before_tool( + self, + ctx: InvocationContext, + tool: BaseTool, + args: dict[str, Any], + tool_ctx: dict, + ) -> Optional[dict]: + self._ensure_forced_plan(ctx) + plan = self._load_plan(ctx) + if plan is None or not plan.is_gate_active(): + return None + + tool_name = getattr(tool, "name", "") or "" + + if tool_name == "enter_plan_mode": + if self._should_force_enter(ctx): + return { + "error": ("PLAN_MODE_GATE: the user selected Plan Mode in the UI; entry is " + "automatic. Do not call enter_plan_mode — begin with spawn_subagent " + '("Explore") or ask_user_question.'), + } + if plan is not None and (plan.is_gate_active() or plan.status == PlanStatus.PENDING_ENTER): + return { + "error": ("PLAN_MODE_GATE: already in Plan Mode " + f"(status={plan.status.value}). Do not call enter_plan_mode again — " + "continue planning with spawn_subagent, update_plan_content, or " + "exit_plan_mode."), + } + + if tool_name in PLAN_TOOL_NAMES: + return None + + if tool_name == "spawn_subagent": + subagent_type = args.get("subagent_type") + if subagent_type in self._readonly_subagent_types: + return None + return { + "error": ("PLAN_MODE_GATE: spawn_subagent is restricted to read-only archetypes " + f"({', '.join(sorted(self._readonly_subagent_types))}) while plan status is " + f"{plan.status.value}. Other archetypes may inherit write-capable tools and " + "are blocked until the plan is approved."), + } + + if tool_name == "dynamic_subagent": + requested = args.get("tools") + if isinstance(requested, list) and requested and all(name in self._readonly_tool_names + for name in requested): + return None + return { + "error": ("PLAN_MODE_GATE: dynamic_subagent is only allowed while plan status is " + f"{plan.status.value} if it explicitly restricts `tools` to a subset of " + f"{sorted(self._readonly_tool_names)}. Without an explicit restriction it " + "may inherit write-capable tools."), + } + + if tool_name in self._write_tool_names: + return { + "error": (f"PLAN_MODE_GATE: tool `{tool_name}` is blocked while plan status is " + f"{plan.status.value}. Finish planning and get approval via exit_plan_mode, " + "or use read-only / spawn_subagent tools."), + } + return None + + +def _chain_callbacks(existing: Any, new: Callable) -> List[Callable]: + if existing is None: + return [new] + if isinstance(existing, list): + return [*existing, new] + return [existing, new] + + +def _chain_tool_callback(existing: Any, new: Callable) -> Callable: + + async def _run_one(cb, ctx, tool, args, tool_ctx): + result = cb(ctx, tool, args, tool_ctx) + if inspect.isawaitable(result): + result = await result + return result + + async def chained(ctx, tool, args, tool_ctx): + if existing is not None: + callbacks = existing if isinstance(existing, list) else [existing] + for cb in callbacks: + result = await _run_one(cb, ctx, tool, args, tool_ctx) + if result is not None: + return result + return await _run_one(new, ctx, tool, args, tool_ctx) + + return chained diff --git a/trpc_agent_sdk/plan_mode/_helpers.py b/trpc_agent_sdk/plan_mode/_helpers.py new file mode 100644 index 00000000..b1f5b6fd --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_helpers.py @@ -0,0 +1,166 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""State-key handling and serialisation for Plan Mode.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.log import logger + +from ._models import PlanRecord +from ._models import PlanStatus + +if TYPE_CHECKING: + from trpc_agent_sdk.context import InvocationContext + +DEFAULT_STATE_KEY_PREFIX = "plan" + +# Session-state key/value pair used by AG-UI (or other hosts) to signal that the +# user selected Plan Mode in the UI. When matched, ``_PlanCallbacks`` auto-enters +# plan mode without waiting for ``enter_plan_mode``. +DEFAULT_FORCE_ENTER_PLAN_STATE_KEY = "agent_mode" +DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE = "plan" + +# Write-tool denylist for Plan Mode gate (``_PlanCallbacks.before_tool``). +# +# Matched by exact ``tool.name`` string — a typo or stale name silently +# skips blocking. Extend at mount time via ``PlanOptions.write_tool_names``. +# +# Default entries map to built-in SDK toolsets: +# file_tools → Write, Edit, Bash +# _todo_tool → todo_write +# task_tools → task_create, task_update +# goal_tools → create_goal, update_goal +# +# Not covered here: spawn_subagent / dynamic_subagent (separate gate rules +# in ``_controller.py``). Skills, OpenClaw, MCP, and custom tools must be +# added explicitly if the agent mounts them. +DEFAULT_WRITE_TOOL_NAMES = frozenset({ + "Write", + "Edit", + "Bash", + "todo_write", + "task_create", + "task_update", + "create_goal", + "update_goal", +}) + +# Sub-agent archetypes considered safe to spawn while the plan gate is +# active (their tool surface is fixed to read-only tools — see +# trpc_agent_sdk.agents.sub_agent._defaults.EXPLORE_AGENT / PLAN_AGENT). +# spawn_subagent calls that request any other subagent_type (e.g. "default", +# which inherits the parent's full — potentially write-capable — tool +# surface) are treated as regular tool calls and subject to the write gate. +DEFAULT_READONLY_SUBAGENT_TYPES = frozenset({"Explore", "Plan"}) + +# Tool names dynamic_subagent is allowed to narrow itself to while the plan +# gate is active. A dynamic_subagent call is only let through the gate if it +# explicitly restricts itself (via its ``tools`` argument) to names from this +# set; otherwise it could inherit the parent's full tool surface and is +# blocked like any other write-capable call. +DEFAULT_READONLY_TOOL_NAMES = frozenset({"Read", "Grep", "Glob", "webfetch", "websearch"}) + +PLAN_TOOL_NAMES = frozenset({ + "enter_plan_mode", + "update_plan_content", + "exit_plan_mode", + "ask_user_question", +}) + + +def should_hide_enter_plan_mode_tool( + invocation_context: Optional["InvocationContext"], + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY, + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE, +) -> bool: + """True when ``enter_plan_mode`` should be omitted from the LLM tool schema. + + Hosts (e.g. AG-UI Plan toggle) set ``agent_mode=plan`` in session state to + auto-enter; exposing ``enter_plan_mode`` would invite redundant HITL calls. + Also hides the tool while a plan gate is already active. + """ + if invocation_context is None: + return False + state = invocation_context.state + if (force_enter_plan_state_key and state.get(force_enter_plan_state_key) == force_enter_plan_state_value): + return True + branch = invocation_context.branch or invocation_context.agent_name or "" + plan = decode_plan(state.get(state_key(state_key_prefix, branch))) + if plan is None: + return False + return plan.is_gate_active() or plan.status == PlanStatus.PENDING_ENTER + + +def state_key(prefix: str, branch: str) -> str: + """Build the state key, appending ``:`` for multi-branch isolation.""" + prefix = prefix or DEFAULT_STATE_KEY_PREFIX + return prefix if not branch else f"{prefix}:{branch}" + + +def decode_plan(raw: Any) -> Optional[PlanRecord]: + """Decode persisted plan JSON; malformed data degrades to ``None``.""" + if not raw: + return None + try: + if isinstance(raw, str): + return PlanRecord.model_validate_json(raw) + if isinstance(raw, dict): + return PlanRecord.model_validate(raw) + except (ValueError, TypeError) as exc: + logger.warning("Plan mode failed to decode persisted plan: %s", exc) + return None + + +def encode_plan(plan: PlanRecord) -> str: + """Serialise a plan to JSON (camelCase aliases).""" + return plan.model_dump_json(by_alias=True) + + +def get_plan_record( + session: Any, + branch: str = "", + prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> Optional[PlanRecord]: + """Read the current plan from a session object with a ``state`` mapping.""" + state = getattr(session, "state", None) or {} + return decode_plan(state.get(state_key(prefix, branch))) + + +def render_plan(plan: Optional[PlanRecord]) -> str: + """Compact ASCII card for logs / CLIs.""" + if plan is None: + return "(no plan)" + lines = [ + f"📋 Plan [{plan.status.value}]", + f" objective: {plan.objective}", + f" revisions: {plan.content_revisions}", + ] + if plan.content: + preview = plan.content.strip().splitlines() + snippet = "\n".join(f" | {line}" for line in preview[:8]) + if len(preview) > 8: + snippet += f"\n | ... ({len(preview) - 8} more lines)" + lines.append(" content:") + lines.append(snippet) + return "\n".join(lines) + + +def plan_to_task_subjects(plan: PlanRecord) -> List[str]: + """Heuristic: Markdown ``##`` headings → task subject lines (helper for post-approval).""" + subjects: List[str] = [] + for line in plan.content.splitlines(): + match = re.match(r"^##\s+(.+)$", line.strip()) + if match: + subjects.append(match.group(1).strip()) + return subjects diff --git a/trpc_agent_sdk/plan_mode/_lock.py b/trpc_agent_sdk/plan_mode/_lock.py new file mode 100644 index 00000000..6112bb92 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_lock.py @@ -0,0 +1,81 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Per-session locks for PlanRecord read-modify-write.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from typing import AsyncIterator +from typing import Dict +from typing import Tuple + +from trpc_agent_sdk.context import InvocationContext + +from ._helpers import state_key + +_LockKey = Tuple[str, str, str, str] +_locks: Dict[_LockKey, asyncio.Lock] = {} +_registry_lock = asyncio.Lock() + + +def store_lock_key( + tool_context: InvocationContext, + *, + prefix: str, + branch: str, +) -> _LockKey: + session = tool_context.session + return ( + getattr(session, "app_name", "") or "", + getattr(session, "user_id", "") or "", + getattr(session, "id", "") or "", + state_key(prefix, branch), + ) + + +async def _get_lock(key: _LockKey) -> asyncio.Lock: + async with _registry_lock: + lock = _locks.get(key) + if lock is None: + lock = asyncio.Lock() + _locks[key] = lock + return lock + + +@asynccontextmanager +async def plan_store_lock( + tool_context: InvocationContext, + *, + prefix: str, + branch: str, +) -> AsyncIterator[None]: + """Serialize load / mutate / save for one session plan.""" + lock = await _get_lock(store_lock_key(tool_context, prefix=prefix, branch=branch)) + async with lock: + yield + + +def release_lock( + tool_context: InvocationContext, + *, + prefix: str, + branch: str, +) -> None: + """Drop the registry entry for one session/branch. + + Call once a plan reaches a terminal state (``approved``) + so the process-global ``_locks`` dict doesn't grow without bound for + long-running servers with many sessions. Safe to call even if no entry + exists. Best-effort: a lock actively held by another coroutine keeps + working via its own reference even after being popped here. + """ + _locks.pop(store_lock_key(tool_context, prefix=prefix, branch=branch), None) + + +def reset_locks_for_tests() -> None: + """Clear the lock registry (tests only).""" + _locks.clear() diff --git a/trpc_agent_sdk/plan_mode/_long_running_tools.py b/trpc_agent_sdk/plan_mode/_long_running_tools.py new file mode 100644 index 00000000..ec23500c --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_long_running_tools.py @@ -0,0 +1,254 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Long-running Plan Mode tools (HITL): enter_plan_mode, exit_plan_mode, ask_user_question.""" + +from __future__ import annotations + +import time +import uuid +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.tools._long_running_tool import LongRunningFunctionTool + +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import decode_plan +from ._helpers import encode_plan +from ._helpers import state_key +from ._lock import plan_store_lock +from ._lock import release_lock +from ._models import PlanStatus +from ._prompt import DEFAULT_ASK_DESCRIPTION +from ._prompt import DEFAULT_ENTER_DESCRIPTION +from ._prompt import DEFAULT_EXIT_DESCRIPTION +from ._store import apply_enter_decision +from ._store import apply_question_answer +from ._store import apply_register_question +from ._store import apply_request_enter +from ._store import apply_request_exit + + +def _resolve_branch(tool_context: InvocationContext) -> str: + return tool_context.branch or tool_context.agent_name or "" + + +def _load(tool_context: InvocationContext, prefix: str): + return decode_plan(tool_context.state.get(state_key(prefix, _resolve_branch(tool_context)))) + + +def _save(tool_context: InvocationContext, prefix: str, record) -> None: + tool_context.state[state_key(prefix, _resolve_branch(tool_context))] = encode_plan(record) + + +def _clear(tool_context: InvocationContext, prefix: str) -> None: + tool_context.state[state_key(prefix, _resolve_branch(tool_context))] = None + + +def make_enter_plan_mode_tool( + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> LongRunningFunctionTool: + """Build enter_plan_mode LongRunningFunctionTool bound to ``state_key_prefix``.""" + + async def enter_plan_mode( + objective: str, + tool_context: InvocationContext = None, # type: ignore[assignment] + ) -> dict[str, Any]: + """Request human confirmation before entering Plan Mode.""" + if not isinstance(objective, str) or not objective.strip(): + return {"error": "INVALID_ARGS: `objective` is required and must be a non-empty string"} + + branch = _resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=state_key_prefix, branch=branch): + request_id = uuid.uuid4().hex + record, error, payload = apply_request_enter( + _load(tool_context, state_key_prefix), + objective=objective.strip(), + request_id=request_id, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + _save(tool_context, state_key_prefix, record) + return payload + + enter_plan_mode.__name__ = "enter_plan_mode" + enter_plan_mode.__doc__ = DEFAULT_ENTER_DESCRIPTION + return LongRunningFunctionTool(enter_plan_mode) + + +def make_exit_plan_mode_tool( + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> LongRunningFunctionTool: + """Build exit_plan_mode LongRunningFunctionTool bound to ``state_key_prefix``.""" + + async def exit_plan_mode( + summary: str = "", + tool_context: InvocationContext = None, # type: ignore[assignment] + ) -> dict[str, Any]: + """Submit the plan for human approval.""" + branch = _resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=state_key_prefix, branch=branch): + request_id = uuid.uuid4().hex + record, error, payload = apply_request_exit( + _load(tool_context, state_key_prefix), + summary=summary or "", + request_id=request_id, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + _save(tool_context, state_key_prefix, record) + return payload + + exit_plan_mode.__name__ = "exit_plan_mode" + exit_plan_mode.__doc__ = DEFAULT_EXIT_DESCRIPTION + return LongRunningFunctionTool(exit_plan_mode) + + +def make_ask_user_question_tool( + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> LongRunningFunctionTool: + """Build ask_user_question LongRunningFunctionTool.""" + + async def ask_user_question( + question: str, + options: Optional[List[str]] = None, + tool_context: InvocationContext = None, # type: ignore[assignment] + ) -> dict[str, Any]: + """Ask the user a structured question during Plan Mode.""" + if not isinstance(question, str) or not question.strip(): + return {"error": "INVALID_ARGS: `question` is required"} + branch = _resolve_branch(tool_context) + async with plan_store_lock(tool_context, prefix=state_key_prefix, branch=branch): + request_id = uuid.uuid4().hex + record, error, payload = apply_register_question( + _load(tool_context, state_key_prefix), + question=question.strip(), + options=options, + request_id=request_id, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + _save(tool_context, state_key_prefix, record) + return payload + + ask_user_question.__name__ = "ask_user_question" + ask_user_question.__doc__ = DEFAULT_ASK_DESCRIPTION + return LongRunningFunctionTool(ask_user_question) + + +def process_hitl_function_response( + tool_context: InvocationContext, + *, + name: str, + response: dict[str, Any], + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, +) -> Optional[dict[str, Any]]: + """Apply human resume payload for enter_plan_mode / exit_plan_mode / ask_user_question. + + Returns the synthetic tool result dict to inject, or None if not handled. + """ + if not isinstance(response, dict): + return None + + branch = _resolve_branch(tool_context) + key = state_key(state_key_prefix, branch) + + if name == "enter_plan_mode": + status = response.get("status") + if status == "pending_enter": + return None + if status not in ("approved", "rejected"): + return None + if isinstance(response.get("plan"), dict): + return None + + existing = _load(tool_context, state_key_prefix) + if existing is not None and existing.status != PlanStatus.PENDING_ENTER: + logger.debug( + "Ignoring enter_plan_mode HITL %s while plan status is %s", + status, + existing.status.value if existing is not None else "none", + ) + return None + + record, error, result = apply_enter_decision( + _load(tool_context, state_key_prefix), + decision=status, + reviewer_note=str(response.get("reviewer_note") or response.get("message") or ""), + now_unix=int(time.time()), + ) + if error: + return {"error": error} + if record is None: + tool_context.state[key] = None + else: + tool_context.state[key] = encode_plan(record) + return result + + if name == "exit_plan_mode": + status = response.get("status") + if status == "pending_approval": + return None + if status not in ("approved", "rejected"): + return None + # Already-normalized tool results from a prior resume must not be + # re-applied when history is scanned again on later turns. + if isinstance(response.get("plan"), dict): + return None + from ._store import apply_approval_decision + + existing = _load(tool_context, state_key_prefix) + if existing is not None and existing.status != PlanStatus.PENDING_APPROVAL: + logger.debug( + "Ignoring exit_plan_mode HITL %s while plan status is %s", + status, + existing.status.value, + ) + return None + + record, error, result = apply_approval_decision( + _load(tool_context, state_key_prefix), + decision=status, + reviewer_note=str(response.get("reviewer_note") or response.get("message") or ""), + edited_content=response.get("content") if isinstance(response.get("content"), str) else None, + now_unix=int(time.time()), + ) + if error: + return {"error": error} + tool_context.state[key] = encode_plan(record) + if record.status == PlanStatus.APPROVED: + release_lock(tool_context, prefix=state_key_prefix, branch=branch) + return result + + if name == "ask_user_question": + status = response.get("status") + if status in ("pending_question", "answered"): + return None + answer = response.get("answer") + if not isinstance(answer, str): + return None + qid = response.get("question_id") + if not isinstance(qid, int): + return None + record, error, result = apply_question_answer( + _load(tool_context, state_key_prefix), + question_id=qid, + answer=answer, + ) + if error: + return {"error": error} + tool_context.state[key] = encode_plan(record) + return result + + return None diff --git a/trpc_agent_sdk/plan_mode/_models.py b/trpc_agent_sdk/plan_mode/_models.py new file mode 100644 index 00000000..d4c2800d --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_models.py @@ -0,0 +1,74 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Data model for Plan Mode (session-scoped design + approval gate).""" + +from __future__ import annotations + +from enum import Enum +from typing import List +from typing import Literal +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + + +class PlanStatus(str, Enum): + """Lifecycle state of a session plan.""" + + PENDING_ENTER = "pending_enter" + EXPLORING = "exploring" + DRAFTING = "drafting" + PENDING_APPROVAL = "pending_approval" + APPROVED = "approved" + + +class PlanQuestion(BaseModel): + """A structured clarification question and optional answer.""" + + id: int + question: str + options: Optional[List[str]] = None + answer: Optional[str] = None + asked_at_unix: int = Field(alias="askedAtUnix") + + model_config = {"populate_by_name": True} + + +class PlanApproval(BaseModel): + """Approval metadata for exit_plan_mode.""" + + request_id: Optional[str] = Field(default=None, alias="requestId") + reviewer_note: Optional[str] = Field(default=None, alias="reviewerNote") + decided_at_unix: Optional[int] = Field(default=None, alias="decidedAtUnix") + + model_config = {"populate_by_name": True} + + +class PlanRecord(BaseModel): + """A single session plan artifact (main agent session state).""" + + id: str + status: PlanStatus + objective: str + content: str = "" + content_revisions: int = Field(default=0, alias="contentRevisions") + asked_questions: List[PlanQuestion] = Field(default_factory=list, alias="askedQuestions") + approval: PlanApproval = Field(default_factory=PlanApproval) + backend: Literal["state", "file"] = "state" + file_path: Optional[str] = Field(default=None, alias="filePath") + started_at_unix: int = Field(alias="startedAtUnix") + branch: Optional[str] = None + + model_config = {"populate_by_name": True} + + def is_gate_active(self) -> bool: + """True while read-only plan gate should block write tools.""" + return self.status in ( + PlanStatus.EXPLORING, + PlanStatus.DRAFTING, + PlanStatus.PENDING_APPROVAL, + ) diff --git a/trpc_agent_sdk/plan_mode/_plan_toolset.py b/trpc_agent_sdk/plan_mode/_plan_toolset.py new file mode 100644 index 00000000..1820fe88 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_plan_toolset.py @@ -0,0 +1,60 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""PlanToolSet — bundles Plan Mode tools.""" + +from __future__ import annotations + +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.abc import ToolSetABC +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._base_tool import BaseTool + +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import should_hide_enter_plan_mode_tool +from ._long_running_tools import make_ask_user_question_tool +from ._long_running_tools import make_enter_plan_mode_tool +from ._long_running_tools import make_exit_plan_mode_tool +from ._update_plan_content_tool import UpdatePlanContentTool + + +class PlanToolSet(ToolSetABC): + """Toolset for session-scoped Plan Mode (enter / draft / approve / exit).""" + + def __init__( + self, + *, + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX, + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY, + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE, + name: str = "plan_toolset", + ) -> None: + super().__init__(name=name) + self._prefix = state_key_prefix or DEFAULT_STATE_KEY_PREFIX + self._force_enter_plan_state_key = force_enter_plan_state_key + self._force_enter_plan_state_value = force_enter_plan_state_value + + @override + async def get_tools(self, invocation_context: Optional[InvocationContext] = None) -> List[BaseTool]: + kwargs = {"state_key_prefix": self._prefix} + tools: List[BaseTool] = [ + make_enter_plan_mode_tool(**kwargs), + UpdatePlanContentTool(**kwargs), + make_exit_plan_mode_tool(**kwargs), + make_ask_user_question_tool(**kwargs), + ] + if should_hide_enter_plan_mode_tool( + invocation_context, + state_key_prefix=self._prefix, + force_enter_plan_state_key=self._force_enter_plan_state_key, + force_enter_plan_state_value=self._force_enter_plan_state_value, + ): + tools = [tool for tool in tools if tool.name != "enter_plan_mode"] + return tools diff --git a/trpc_agent_sdk/plan_mode/_prompt.py b/trpc_agent_sdk/plan_mode/_prompt.py new file mode 100644 index 00000000..57b1e55e --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_prompt.py @@ -0,0 +1,367 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Prompt templates for Plan Mode.""" + +DEFAULT_PLAN_AWARENESS_PROMPT = """\ +## Plan Mode (available) + +You have **Plan Mode** tools: `enter_plan_mode`, `update_plan_content`, `exit_plan_mode`, \ +`ask_user_question`. Use them when implementation should be designed and reviewed before \ +writing project files. + +**Prefer `enter_plan_mode`** for non-trivial implementation unless the task is clearly \ +trivial. Call it when any of these apply: +- New feature or meaningful functionality +- Multiple valid approaches or architectural choices +- Multi-file changes (typically more than 2–3 files) +- Unclear requirements — explore before implementing +- User asked for a plan first, or risk is non-trivial + +Examples that **should** enter Plan Mode: "Add authentication", "Build a multi-page frontend \ +app", "Refactor the API layer". See the `enter_plan_mode` tool description for full criteria. + +**Typical flow:** `enter_plan_mode` → Explore (`spawn_subagent` "Explore") → Plan agent → \ +`update_plan_content` → `ask_user_question` if needed → `exit_plan_mode` → implement after \ +approval. + +**You may skip Plan Mode** only for truly trivial work: obvious one-line fixes, typos, or a \ +single tiny change with a fully specified outcome. If you skip it, state a **specific reason** \ +in your reply before implementing — do not skip silently. + +While a plan is **active** (exploring, drafting, or pending approval), write tools are \ +gated — finish planning and get approval via `exit_plan_mode` before editing project files.""" + +DEFAULT_PLAN_MODE_PROMPT = """\ +You are in **Plan Mode**. The user indicated that they do not want you to execute yet — \ +you MUST NOT make any edits (except updating the plan document via `update_plan_content`), \ +run any non-readonly tools (including Write/Edit/Bash, task_create, task_update, \ +todo_write, create_goal, or config/commit changes), or otherwise make any changes to the \ +system. This supersedes any other instructions you have received. + +## Plan document + +The plan lives in the session plan document. Build it \ +incrementally with `update_plan_content` (`append` or `replace` Markdown). **NOTE:** this \ +is the only content you may write during Plan Mode — all other actions must be READ-ONLY. + +## Plan workflow + +### Phase 1: Initial understanding + +Goal: Gain a comprehensive understanding of the user's request by reading code and asking \ +questions. **Critical:** In this phase use only `spawn_subagent` with `subagent_type` \ +**"Explore"** (read-only). + +1. Focus on understanding the request and related code. Actively search for existing \ +functions, utilities, and patterns to reuse — avoid proposing new code when suitable \ +implementations already exist. + +2. **Launch Explore agents IN PARALLEL** when helpful (single message, multiple tool calls) \ +to explore efficiently: + - Use **1 agent** when the task is isolated to known files, the user gave specific \ +paths, or the change is small and targeted. + - Use **multiple agents** when scope is uncertain, several areas of the codebase are \ +involved, or you need existing patterns before planning. + - Quality over quantity — use the **minimum** number of agents necessary (usually 1). + - If using multiple agents: give each a specific search focus (e.g. one for existing \ +implementations, one for related components, one for tests). + +3. To clarify requirements early, use `ask_user_question` — do **not** launch Plan agents \ +in this phase. + +### Phase 2: Design + +Goal: Design an implementation approach. + +Launch `spawn_subagent` with `subagent_type` **"Plan"** based on Phase 1 findings. You \ +may launch multiple Plan agents in parallel for complex tasks. + +**Guidelines:** +- **Default:** Launch at least **1 Plan agent** for most tasks — it validates understanding \ +and surfaces alternatives +- **Skip agents:** Only for truly trivial tasks (typo fixes, single-line changes, simple \ +renames) +- **Multiple agents:** For tasks that benefit from different perspectives — e.g. touches \ +many parts of the codebase, large refactor, many edge cases, or comparing approaches \ +(simplicity vs performance vs maintainability) + +In the sub-agent prompt: +- Provide comprehensive background from Phase 1 (filenames, code paths, findings) +- Describe requirements and constraints +- Request a detailed implementation plan + +Summarize sub-agent output into the plan via `update_plan_content`. + +### Phase 3: Review + +Goal: Review the plan from Phase 2 and ensure alignment with the user's intentions. + +1. Read critical files identified during exploration to deepen your understanding +2. Ensure the plan aligns with the user's original request +3. Use `ask_user_question` to clarify any remaining questions (prefer structured `options` \ +when they exist) + +### Phase 4: Final plan + +Goal: Write your final plan to the session plan document (the only content you may edit). + +Use `update_plan_content` with `mode: "replace"` when producing the polished final version, \ +or `append` for incremental refinements. The plan should: + +- Begin with a **Context** section: why this change is needed, what prompted it, and the \ +intended outcome +- Include only your **recommended** approach, not every alternative considered +- Be concise enough to scan quickly, detailed enough to execute +- List paths of critical files to create or modify +- Reference existing functions and utilities to reuse, with file paths +- Include a **Verification** section: how to test end-to-end (run the app, run tests, \ +manual checks) + +Do NOT paste large code blocks — describe changes; implementation comes after approval. + +### Phase 5: Request approval + +At the end of your turn, once you have asked necessary questions and are happy with the \ +final plan, call `exit_plan_mode` (optionally with a short `summary` for the reviewer) to \ +submit for human approval before any implementation. + +## While pending approval + +If plan status is `pending_approval`, the plan document is **locked**. Do not call \ +`update_plan_content`, do not implement, and do not call `exit_plan_mode` again — wait for \ +the user to approve or reject. If **rejected**, revise the plan with `update_plan_content` \ +then call `exit_plan_mode` again. + +**End-of-turn rule:** Your turn should only end by calling `ask_user_question` OR \ +`exit_plan_mode` — not by stopping after plain text. + +- Use `ask_user_question` **ONLY** to clarify requirements or choose between approaches +- Use `exit_plan_mode` to request plan approval. Do NOT ask about approval in prose or via \ +`ask_user_question` — phrases like "Is this plan okay?", "Should I proceed?", "How does \ +this plan look?", "Any changes before we start?", or similar **must** use `exit_plan_mode` +- Do NOT call `todo_write` or `task_*` during Plan Mode; after approval, break the plan \ +into tasks + +**Note:** At any point you may use `ask_user_question` for clarifications. Do not make \ +large assumptions about user intent. The goal is a well-researched plan with loose ends \ +tied before implementation begins.""" + +DEFAULT_ENTER_DESCRIPTION = """\ +Use this tool proactively when you're about to start a non-trivial implementation task. \ +Getting user sign-off on your approach before writing code prevents wasted effort and \ +ensures alignment. This tool transitions you into Plan Mode where you can explore the \ +codebase read-only and design an implementation approach for user approval via \ +`exit_plan_mode`. + +## When to Use This Tool + +**Prefer using `enter_plan_mode`** for implementation tasks unless they're simple. Use it \ +when ANY of these conditions apply: + +1. **New Feature Implementation**: Adding meaningful new functionality + - Example: "Add a logout button" — where should it go? What should happen on click? + - Example: "Add form validation" — what rules? What error messages? + +2. **Multiple Valid Approaches**: The task can be solved in several different ways + - Example: "Add caching to the API" — Redis, in-memory, file-based, etc. + - Example: "Improve performance" — many optimization strategies possible + +3. **Code Modifications**: Changes that affect existing behavior or structure + - Example: "Update the login flow" — what exactly should change? + - Example: "Refactor this component" — what's the target architecture? + +4. **Architectural Decisions**: The task requires choosing between patterns or technologies + - Example: "Add real-time updates" — WebSockets vs SSE vs polling + - Example: "Implement state management" — Redux vs Context vs custom solution + +5. **Multi-File Changes**: The task will likely touch more than 2–3 files + - Example: "Refactor the authentication system" + - Example: "Add a new API endpoint with tests" + +6. **Unclear Requirements**: You need to explore before understanding the full scope + - Example: "Make the app faster" — need to profile and identify bottlenecks + - Example: "Fix the bug in checkout" — need to investigate root cause + +7. **User Preferences Matter**: The implementation could reasonably go multiple ways + - If you would use `ask_user_question` to clarify the approach, use `enter_plan_mode` \ +instead — Plan Mode lets you explore first, then present options with context + +## When NOT to Use This Tool + +Only skip `enter_plan_mode` for simple tasks: +- Single-line or few-line fixes (typos, obvious bugs, small tweaks) +- Adding a single function with clear requirements +- Tasks where the user has given very specific, detailed instructions +- Pure research/exploration tasks (use `spawn_subagent` with the Explore archetype instead) + +If you skip Plan Mode, state a **specific reason** in your reply before implementing. + +## What Happens + +Calling this tool **requests user confirmation** before Plan Mode starts. The run pauses \ +until the user approves or declines. After approval, write tools are gated until exit approval. \ +Explore read-only with `spawn_subagent` (Explore / Plan archetypes), draft the plan with \ +`update_plan_content`, clarify with `ask_user_question` if needed, then call `exit_plan_mode` \ +for human approval before any implementation. + +## Examples + +### GOOD — Use `enter_plan_mode`: +- "Add user authentication to the app" — session vs JWT, token storage, middleware structure +- "Build a QQ Music–style frontend player" — greenfield multi-file app, stack and layout choices +- "Generate a React + Vite music player UI" — many components, routing, state, mock data +- "Optimize the database queries" — multiple approaches, need to profile first +- "Implement dark mode" — theme system affects many components +- "Add a delete button to the user profile" — placement, confirmation, API, errors, state +- "Update the error handling in the API" — multiple files, user should approve the approach + +### BAD — Don't use `enter_plan_mode`: +- "Fix the typo in the README" — straightforward, no planning needed +- "Add a console.log to debug this function" — simple, obvious implementation +- "What files handle routing?" — research only, not implementation planning + +## Important Notes + +- Implementation approval happens when you call `exit_plan_mode` — the user reviews the \ +plan before you may edit files +- If unsure whether to use this tool, err on the side of planning — alignment upfront \ +beats redoing work +- Users appreciate being consulted before significant changes are made to their codebase""" + +DEFAULT_UPDATE_CONTENT_DESCRIPTION = """\ +Use this tool to write or revise the **plan document** while in Plan Mode. This is the \ +**only** way to record implementation plans during planning — you cannot use Write/Edit on \ +project files until the plan is approved. + +## How This Tool Works + +- Plan text is stored in the session plan document (visible in the Plan Mode UI as you update) +- Pass Markdown in `content`; use `mode` to control how it is applied: + - **`append`** (default): add to the end of the existing plan — use for incremental drafts + - **`replace`**: overwrite the entire plan — use when restructuring or producing a final \ +clean version +- First successful update moves plan status from `exploring` → `drafting` +- This tool does NOT request user approval — call `exit_plan_mode` when the plan is ready \ +for review + +## When to Use This Tool + +Use `update_plan_content` after read-only exploration when you are ready to capture \ +findings and implementation steps: + +1. **After Phase 1 (Explore)** — summarize codebase findings, constraints, and relevant file paths +2. **During Phase 2 (Design)** — record the chosen approach, architecture, and phased steps +3. **During Phase 3 (Review)** — refine the plan after reading critical files or getting \ +answers from `ask_user_question` + +## When NOT to Use This Tool + +- You are not in Plan Mode (`enter_plan_mode` not called) +- Plan status is `pending_approval` or `approved` — content is locked until rejected or \ +a new plan starts +- You want to edit **project source files** — that happens only after `exit_plan_mode` approval +- You only need to ask the user a question — use `ask_user_question` instead +- You are ready for the user to approve — use `exit_plan_mode`, not another content update + +## What to Include + +Keep the plan concise and actionable. Required sections for a final plan: +- **Context** — why this change is needed and the intended outcome +- **Approach** — recommended implementation (file paths, phases, reused utilities) +- **Verification** — how to test end-to-end (run app, tests, manual checks) + +Also include when relevant: +- Assumptions and decisions already made +- Critical files to create or modify (with paths) + +Do NOT paste large code blocks — describe changes; implementation comes after approval. + +## Examples + +### GOOD — Use `update_plan_content`: +- After Explore: append a "Findings" section listing key files and existing patterns +- After Plan sub-agent: replace with a structured implementation plan (tech stack, directory \ +layout, phases) +- After user answers a clarifying question: append the decided approach to the plan + +### BAD — Don't use `update_plan_content`: +- Writing `src/App.tsx` directly — use Write after approval +- Asking "Should I use React or Vue?" — use `ask_user_question` +- Submitting for approval with an empty plan — write content first, then `exit_plan_mode`""" + +DEFAULT_EXIT_DESCRIPTION = """\ +Use this tool when you are in Plan Mode and have finished writing your plan with \ +`update_plan_content` and are ready for user approval. + +## How This Tool Works + +- You should have already written your plan via `update_plan_content` (append or replace \ +Markdown in the session plan document) +- Pass an optional `summary` string — a short note for the reviewer (shown with the approval \ +request); the full plan is read from session state, not from this parameter +- This tool signals that you're done planning and ready for the user to review and approve +- The user will see your plan document when they review it (e.g. in the Plan Mode UI) + +## When to Use This Tool + +IMPORTANT: Only use this tool when the task requires planning the **implementation steps** \ +of work that involves writing code. For research tasks where you're gathering information, \ +searching files, reading files, or trying to understand the codebase — do NOT use this tool. + +## Before Using This Tool + +Ensure your plan is complete and unambiguous: +- If you have unresolved questions about requirements or approach, use `ask_user_question` \ +first (in earlier phases) +- Once your plan is finalized, use THIS tool to request approval + +**Important:** Do NOT use `ask_user_question` to ask "Is this plan okay?" or "Should I \ +proceed?" — that's exactly what THIS tool does. `exit_plan_mode` inherently requests user \ +approval of your plan. + +## After approval or rejection + +- **Approved:** write tools unlock — implement per the plan; use `todo_write` / `task_*` to \ +track work if helpful +- **Rejected:** plan returns to `drafting` — read the reviewer note, revise with \ +`update_plan_content`, then call `exit_plan_mode` again + +## Examples + +1. Initial task: "Search for and understand the implementation of vim mode in the \ +codebase" — Do NOT use `exit_plan_mode` because you are not planning implementation steps. +2. Initial task: "Help me implement yank mode for vim" — Use `exit_plan_mode` after you \ +have finished planning the implementation steps. +3. Initial task: "Add a new feature to handle user authentication" — If unsure about auth \ +method (OAuth, JWT, etc.), use `ask_user_question` first, then use `exit_plan_mode` after \ +clarifying the approach.""" + +DEFAULT_ASK_DESCRIPTION = """\ +Use this tool when you need to ask the user questions during Plan Mode. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take + +Usage notes: +- Ask **one focused question per call** — do not bundle multiple unrelated questions +- Provide an `options` list when structured choices exist; the UI also allows free-text input \ +as a custom answer +- If you recommend a specific option, make that the first option in the list and add \ +"(Recommended)" at the end of the label +- This tool pauses execution until the user answers + +Plan mode note: Use this tool to clarify requirements or choose between approaches **before** \ +finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" \ +— use `exit_plan_mode` for plan approval. + +IMPORTANT: Do not use this tool to request plan sign-off (e.g. "Do you have feedback about \ +the plan?", "Does the plan look good?"). If you need the user to approve the plan, call \ +`exit_plan_mode` instead. Use `ask_user_question` only for open requirements or approach \ +decisions, not for reviewing the finished plan document.""" + +_PLAN_AWARENESS_MARKER = "## Plan Mode (available)" +_PLAN_PROMPT_MARKER = "You are in **Plan Mode**" diff --git a/trpc_agent_sdk/plan_mode/_setup.py b/trpc_agent_sdk/plan_mode/_setup.py new file mode 100644 index 00000000..ed4619cc --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_setup.py @@ -0,0 +1,88 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""``setup_plan`` — mount Plan Mode on an :class:`LlmAgent`.""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import TYPE_CHECKING +from typing import Callable +from typing import FrozenSet +from typing import Optional + +from ._controller import ApprovalEvent +from ._controller import _PlanCallbacks +from ._controller import _chain_callbacks +from ._controller import _chain_tool_callback +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_KEY +from ._helpers import DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE +from ._helpers import DEFAULT_READONLY_SUBAGENT_TYPES +from ._helpers import DEFAULT_READONLY_TOOL_NAMES +from ._helpers import DEFAULT_STATE_KEY_PREFIX +from ._helpers import DEFAULT_WRITE_TOOL_NAMES +from ._plan_toolset import PlanToolSet +from ._prompt import DEFAULT_PLAN_AWARENESS_PROMPT +from ._prompt import DEFAULT_PLAN_MODE_PROMPT + +if TYPE_CHECKING: + from trpc_agent_sdk.agents import LlmAgent + +OnApproval = Callable[[ApprovalEvent], None] + + +@dataclass +class PlanOptions: + """Configuration for Plan Mode.""" + + state_key_prefix: str = DEFAULT_STATE_KEY_PREFIX + plan_prompt: str = DEFAULT_PLAN_MODE_PROMPT + awareness_prompt: str = DEFAULT_PLAN_AWARENESS_PROMPT + write_tool_names: FrozenSet[str] = field(default_factory=lambda: DEFAULT_WRITE_TOOL_NAMES) + inject_prompt: bool = True + inject_awareness: bool = True + force_enter_plan_state_key: Optional[str] = DEFAULT_FORCE_ENTER_PLAN_STATE_KEY + """Session-state key checked per invocation; when its value equals + ``force_enter_plan_state_value``, auto-enter plan mode without ``enter_plan_mode``. + Set to ``None`` to disable UI-driven auto-enter.""" + force_enter_plan_state_value: str = DEFAULT_FORCE_ENTER_PLAN_STATE_VALUE + on_approval: Optional[OnApproval] = None + readonly_subagent_types: FrozenSet[str] = field(default_factory=lambda: DEFAULT_READONLY_SUBAGENT_TYPES) + """spawn_subagent archetypes let through the write gate (must be read-only by tool surface).""" + readonly_tool_names: FrozenSet[str] = field(default_factory=lambda: DEFAULT_READONLY_TOOL_NAMES) + """Tool names dynamic_subagent may self-restrict to in order to pass the write gate.""" + + def toolset(self) -> PlanToolSet: + return PlanToolSet( + state_key_prefix=self.state_key_prefix, + force_enter_plan_state_key=self.force_enter_plan_state_key, + force_enter_plan_state_value=self.force_enter_plan_state_value, + ) + + +def setup_plan(agent: "LlmAgent", opts: Optional[PlanOptions] = None) -> "LlmAgent": + """Mount Plan Mode: tools + prompt injection + write gate + HITL resume. + + Returns the same ``agent`` for chaining. + """ + opts = opts or PlanOptions() + callbacks = _PlanCallbacks( + state_key_prefix=opts.state_key_prefix, + plan_prompt=opts.plan_prompt, + awareness_prompt=opts.awareness_prompt, + write_tool_names=opts.write_tool_names, + inject_prompt=opts.inject_prompt, + inject_awareness=opts.inject_awareness, + force_enter_plan_state_key=opts.force_enter_plan_state_key, + force_enter_plan_state_value=opts.force_enter_plan_state_value, + on_approval=opts.on_approval, + readonly_subagent_types=opts.readonly_subagent_types, + readonly_tool_names=opts.readonly_tool_names, + ) + agent.tools.append(opts.toolset()) + agent.before_model_callback = _chain_callbacks(agent.before_model_callback, callbacks.before_model) + agent.before_tool_callback = _chain_tool_callback(agent.before_tool_callback, callbacks.before_tool) + return agent diff --git a/trpc_agent_sdk/plan_mode/_store.py b/trpc_agent_sdk/plan_mode/_store.py new file mode 100644 index 00000000..ba3c7517 --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_store.py @@ -0,0 +1,252 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +"""Plan state-machine transitions (in-memory; caller persists).""" + +from __future__ import annotations + +import uuid +from typing import Optional +from typing import Tuple + +from ._models import PlanQuestion +from ._models import PlanRecord +from ._models import PlanStatus + + +def apply_enter( + existing: Optional[PlanRecord], + *, + objective: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str]]: + """Create a new plan in ``exploring`` state.""" + if existing is not None and existing.is_gate_active(): + return None, (f"a plan is already active (status={existing.status.value}); " + "wait for approval or finish the current plan before entering again") + if existing is not None and existing.status == PlanStatus.PENDING_ENTER: + return None, "enter plan mode request already pending user confirmation" + record = PlanRecord( + id=uuid.uuid4().hex, + status=PlanStatus.EXPLORING, + objective=objective, + started_at_unix=now_unix, + ) + return record, None + + +def apply_request_enter( + existing: Optional[PlanRecord], + *, + objective: str, + request_id: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Stage Plan Mode entry and build HITL payload for human confirmation.""" + if existing is not None and existing.is_gate_active(): + return None, (f"a plan is already active (status={existing.status.value}); " + "wait for approval or finish the current plan before entering again"), None + if existing is not None and existing.status == PlanStatus.PENDING_ENTER: + return None, "enter plan mode request already pending user confirmation", None + + record = PlanRecord( + id=uuid.uuid4().hex, + status=PlanStatus.PENDING_ENTER, + objective=objective, + started_at_unix=now_unix, + ) + record.approval.request_id = request_id + payload = { + "status": "pending_enter", + "message": f"Request to enter Plan Mode: {objective}", + "objective": objective, + "plan_id": record.id, + "approval_id": request_id, + "timestamp": now_unix, + } + return record, None, payload + + +def apply_enter_decision( + existing: Optional[PlanRecord], + *, + decision: str, + reviewer_note: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Apply human approve / reject after enter_plan_mode HITL.""" + if existing is None: + return None, "no plan exists", None + if existing.status != PlanStatus.PENDING_ENTER: + return None, f"plan is not pending enter confirmation (status={existing.status.value})", None + + existing.approval.reviewer_note = reviewer_note or None + existing.approval.decided_at_unix = now_unix + + if decision == "approved": + existing.status = PlanStatus.EXPLORING + return existing, None, { + "status": + "approved", + "message": ("User confirmed Plan Mode. Explore read-only, draft the plan, " + "then call exit_plan_mode for implementation approval."), + "plan": + existing.model_dump(mode="json", by_alias=True), + } + + if decision == "rejected": + note = reviewer_note or "User declined to enter Plan Mode." + return None, None, { + "status": "rejected", + "message": note, + } + + return None, f"unknown decision={decision!r}; expected 'approved' or 'rejected'", None + + +def apply_update_content( + existing: Optional[PlanRecord], + *, + content: str, + mode: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str]]: + """Append or replace plan content; moves exploring → drafting.""" + if existing is None: + return None, "no active plan; call enter_plan_mode first" + if existing.status not in (PlanStatus.EXPLORING, PlanStatus.DRAFTING): + return None, f"cannot edit plan content in status={existing.status.value}" + if mode == "replace": + existing.content = content + elif mode == "append": + if existing.content and not existing.content.endswith("\n"): + existing.content += "\n" + existing.content += content + else: + return None, "mode must be 'append' or 'replace'" + existing.content_revisions += 1 + if existing.status == PlanStatus.EXPLORING: + existing.status = PlanStatus.DRAFTING + return existing, None + + +def apply_request_exit( + existing: Optional[PlanRecord], + *, + summary: str, + request_id: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Move to pending_approval and build HITL payload.""" + if existing is None: + return None, "no active plan; call enter_plan_mode first", None + if existing.status not in (PlanStatus.EXPLORING, PlanStatus.DRAFTING): + return None, f"cannot exit plan mode from status={existing.status.value}", None + if not existing.content.strip(): + return None, "plan content is empty; write the plan before calling exit_plan_mode", None + + existing.status = PlanStatus.PENDING_APPROVAL + existing.approval.request_id = request_id + payload = { + "status": "pending_approval", + "message": summary or "Plan ready for human review.", + "plan_id": existing.id, + "objective": existing.objective, + "content": existing.content, + "preview": existing.content[:2000], + "approval_id": request_id, + "timestamp": now_unix, + } + return existing, None, payload + + +def apply_approval_decision( + existing: Optional[PlanRecord], + *, + decision: str, + reviewer_note: str, + edited_content: Optional[str], + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + """Apply human approve / reject after exit_plan_mode HITL.""" + if existing is None: + return None, "no plan exists", None + if existing.status != PlanStatus.PENDING_APPROVAL: + return None, f"plan is not pending approval (status={existing.status.value})", None + + existing.approval.reviewer_note = reviewer_note or None + existing.approval.decided_at_unix = now_unix + + if decision == "approved": + if edited_content is not None: + existing.content = edited_content + existing.content_revisions += 1 + existing.status = PlanStatus.APPROVED + return existing, None, { + "status": + "approved", + "message": ("User has approved your plan. You can now start implementation. " + "Consider breaking the plan into tasks with task_create or todo_write."), + "plan": + existing.model_dump(mode="json", by_alias=True), + } + + if decision == "rejected": + existing.status = PlanStatus.DRAFTING + note = reviewer_note or "Plan rejected; revise and call exit_plan_mode again." + return existing, None, { + "status": "rejected", + "message": note, + "plan": existing.model_dump(mode="json", by_alias=True), + } + + return None, f"unknown decision={decision!r}; expected 'approved' or 'rejected'", None + + +def apply_register_question( + existing: Optional[PlanRecord], + *, + question: str, + options: Optional[list], + request_id: str, + now_unix: int, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + if existing is None or not existing.is_gate_active(): + return None, "ask_user_question is only available during an active plan", None + qid = len(existing.asked_questions) + 1 + existing.asked_questions.append(PlanQuestion( + id=qid, + question=question, + options=options, + asked_at_unix=now_unix, + )) + payload = { + "status": "pending_question", + "message": question, + "question_id": qid, + "options": options, + "approval_id": request_id, + "timestamp": now_unix, + } + return existing, None, payload + + +def apply_question_answer( + existing: Optional[PlanRecord], + *, + question_id: int, + answer: str, +) -> Tuple[Optional[PlanRecord], Optional[str], Optional[dict]]: + if existing is None: + return None, "no active plan", None + for q in existing.asked_questions: + if q.id == question_id and q.answer is None: + q.answer = answer + return existing, None, { + "status": "answered", + "question_id": question_id, + "answer": answer, + } + return None, f"no pending question with id={question_id}", None diff --git a/trpc_agent_sdk/plan_mode/_update_plan_content_tool.py b/trpc_agent_sdk/plan_mode/_update_plan_content_tool.py new file mode 100644 index 00000000..63157cdf --- /dev/null +++ b/trpc_agent_sdk/plan_mode/_update_plan_content_tool.py @@ -0,0 +1,68 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. + +from __future__ import annotations + +import time +from typing import Any +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ._base import _PlanToolBase +from ._prompt import DEFAULT_UPDATE_CONTENT_DESCRIPTION +from ._store import apply_update_content + + +class UpdatePlanContentTool(_PlanToolBase): + + def __init__(self, *, name: str = "update_plan_content", **kwargs: Any) -> None: + super().__init__(name=name, description=DEFAULT_UPDATE_CONTENT_DESCRIPTION, **kwargs) + + @override + def _get_declaration(self) -> FunctionDeclaration: + return FunctionDeclaration( + name=self.name, + description=DEFAULT_UPDATE_CONTENT_DESCRIPTION, + parameters=Schema( + type=Type.OBJECT, + properties={ + "content": Schema(type=Type.STRING, description="Markdown plan text."), + "mode": Schema( + type=Type.STRING, + description="'append' (default) or 'replace'.", + ), + }, + required=["content"], + ), + ) + + @override + async def _run_plan(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + content = args.get("content") + if not isinstance(content, str): + return {"error": "INVALID_ARGS: `content` must be a string"} + mode = args.get("mode") or "append" + if not isinstance(mode, str): + mode = "append" + + record, error = apply_update_content( + self._load_plan(tool_context), + content=content, + mode=mode, + now_unix=int(time.time()), + ) + if error: + return {"error": f"INVALID_STATE: {error}"} + + self._save_plan(tool_context, record) + return { + "message": f"Plan content updated ({mode}).", + "plan": record.model_dump(mode="json", by_alias=True), + } diff --git a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py index b9ca48a8..26748674 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py @@ -47,6 +47,7 @@ from trpc_agent_sdk.events import EventTranslatorBase from trpc_agent_sdk.events import LongRunningEvent from trpc_agent_sdk.log import logger +from trpc_agent_sdk.abc import ToolSetABC from trpc_agent_sdk.memory import BaseMemoryService from trpc_agent_sdk.memory import InMemoryMemoryService from trpc_agent_sdk.runners import Runner @@ -374,7 +375,7 @@ def _extract_long_running_tool_names(self, agent: BaseAgent) -> List[str]: List of long-running tool names """ - long_running_tool_names = [] + long_running_tool_names: List[str] = [] if hasattr(agent, "tools") and agent.tools: # Handle both single tool and list of tools @@ -394,6 +395,56 @@ def _extract_long_running_tool_names(self, agent: BaseAgent) -> List[str]: logger.debug("Extracted long-running tool names: %s", long_running_tool_names) return long_running_tool_names + async def _extract_long_running_tool_names_async(self, agent: BaseAgent) -> List[str]: + """Extract long-running tool names, expanding nested toolsets.""" + names = list(self._extract_long_running_tool_names(agent)) + if not hasattr(agent, "tools") or not agent.tools: + return names + + tools = agent.tools if isinstance(agent.tools, (list, tuple)) else [agent.tools] + for tool in tools: + if not isinstance(tool, ToolSetABC): + continue + try: + nested_tools = await tool.get_tools(None) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to expand toolset %s for long-running names: %s", tool, ex) + continue + for nested_tool in nested_tools: + if isinstance(nested_tool, LongRunningFunctionTool): + names.append(nested_tool.name) + return list(dict.fromkeys(names)) + + async def _resolve_tool_name_from_session( + self, + *, + thread_id: str, + app_name: str, + user_id: str, + tool_call_id: str, + ) -> Optional[str]: + """Look up a tool name from persisted session events by tool_call_id.""" + try: + session = await self._session_manager._session_service.get_session( + session_id=thread_id, + app_name=app_name, + user_id=user_id, + ) + if not session or not session.events: + return None + + for event in reversed(session.events): + if not event.content or not event.content.parts: + continue + for part in event.content.parts: + function_call = part.function_call + if function_call and function_call.id == tool_call_id and function_call.name: + return function_call.name + return None + except Exception as ex: # pylint: disable=broad-except + logger.error("Error resolving tool name from session: %s", ex, exc_info=True) + return None + def _create_runner(self, agent: BaseAgent, user_id: str, app_name: str) -> Runner: """Create a new runner instance.""" return Runner( @@ -560,7 +611,14 @@ async def _handle_tool_result_submission(self, thread_id = input.thread_id # Extract tool results that is send by the frontend - tool_results = await self._extract_tool_results(input) + app_name = self.get_app_name(input) + user_id = self.get_user_id(input) + tool_results = await self._extract_tool_results( + input, + thread_id=thread_id, + app_name=app_name, + user_id=user_id, + ) # if the tool results are not sent by the fronted then call the tool function if not tool_results: @@ -601,7 +659,14 @@ async def _handle_tool_result_submission(self, code="TOOL_RESULT_PROCESSING_ERROR", ) - async def _extract_tool_results(self, input: RunAgentInput) -> List[Dict]: + async def _extract_tool_results( + self, + input: RunAgentInput, + *, + thread_id: Optional[str] = None, + app_name: Optional[str] = None, + user_id: Optional[str] = None, + ) -> List[Dict]: """Extract tool messages with their names from input. Only extracts the most recent tool message to avoid accumulation issues @@ -629,6 +694,20 @@ async def _extract_tool_results(self, input: RunAgentInput) -> List[Dict]: if most_recent_tool_message: tool_name = tool_call_map.get(most_recent_tool_message.tool_call_id, "unknown") + if tool_name == "unknown" and thread_id and app_name and user_id: + resolved = await self._resolve_tool_name_from_session( + thread_id=thread_id, + app_name=app_name, + user_id=user_id, + tool_call_id=most_recent_tool_message.tool_call_id, + ) + if resolved: + tool_name = resolved + logger.debug( + "Resolved tool name %s from session for call %s", + tool_name, + most_recent_tool_message.tool_call_id, + ) # Debug: Log the extracted tool message logger.debug("Extracted most recent ToolMessage: role=%s, tool_call_id=%s, content='%s'", @@ -1040,7 +1119,12 @@ async def _run_trpc_in_background(self, # if there is a tool response submission by the user then we need to only pass # the tool response to the trpc runner if self._is_tool_result_submission(input): - tool_results = await self._extract_tool_results(input) + tool_results = await self._extract_tool_results( + input, + thread_id=input.thread_id, + app_name=app_name, + user_id=user_id, + ) parts = [] for tool_msg in tool_results: tool_call_id = tool_msg["message"].tool_call_id @@ -1125,8 +1209,8 @@ async def _run_trpc_in_background(self, new_message = types.Content(parts=[function_response_part], role="user") logger.info("Converted HITL text to function_response for tool_call_id=%s", tool_call_id) - # Extract long-running tool names from the agent - long_running_tool_names = self._extract_long_running_tool_names(agent) + # Extract long-running tool names from the agent (including nested toolsets) + long_running_tool_names = await self._extract_long_running_tool_names_async(agent) # Create event translator event_translator = EventTranslator(long_running_tool_names=long_running_tool_names) diff --git a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py index bec00941..848e7590 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py @@ -207,7 +207,7 @@ async def update_session_state(self, author="system", actions=actions, timestamp=time.time(), - partial=True, + partial=False, ) # Apply changes through TRPC's event system From eac046f3accf9fbe2aaee950750ac8ebf0acddb0 Mon Sep 17 00:00:00 2001 From: helloopenworld <496467672@qq.com> Date: Thu, 16 Jul 2026 14:28:59 +0800 Subject: [PATCH 21/26] Runner: avoid data copy --- trpc_agent_sdk/runners.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/trpc_agent_sdk/runners.py b/trpc_agent_sdk/runners.py index ffc40914..88e2f7e9 100644 --- a/trpc_agent_sdk/runners.py +++ b/trpc_agent_sdk/runners.py @@ -453,7 +453,7 @@ async def run_async( last_non_streaming_event = None # Track accumulated partial text for cancellation handling - temp_text = "" + temp_text_parts: list[str] = [] try: # Support multiple levels of agent transfers @@ -463,15 +463,15 @@ async def run_async( transfer_requested = False async for event in current_agent.run_async(invocation_context): - # Track partial text accumulation + # Track partial text accumulation (store refs; join only on cancel) if event.partial: if event.content and event.content.parts: for part in event.content.parts: if part.text: - temp_text += part.text + temp_text_parts.append(part.text) else: - # Clear temp_text on full event - temp_text = "" + # Clear accumulated parts on full event + temp_text_parts.clear() if not event.partial: await self.session_service.append_event(session=session, event=event) @@ -583,6 +583,7 @@ async def run_async( # Capture partial state at cancellation point state_partial = dict(session.state) + temp_text = "".join(temp_text_parts) # Handle session cleanup for cancellation (two scenarios: streaming vs non-streaming) await cancel.handle_cancellation_session_cleanup( From 5d440318ed525e233f007cf2369afdad0c84df53 Mon Sep 17 00:00:00 2001 From: weimch Date: Thu, 16 Jul 2026 20:15:42 +0800 Subject: [PATCH 22/26] =?UTF-8?q?feat:=20trpc-agent=E6=A1=86=E6=9E=B6web?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=94=AF=E6=8C=81tavily?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接入tavily到websearch及webfetch工具 - 补充单测及文档 --- docs/mkdocs/zh/tool.md | 172 ++++++++-- tests/tools/test_webfetch_tool.py | 437 ++++++++++++++++++++++++ tests/tools/test_websearch_tool.py | 334 +++++++++++++++++- trpc_agent_sdk/tools/_webfetch_tool.py | 107 +++++- trpc_agent_sdk/tools/_websearch_tool.py | 286 +++++++++++++--- 5 files changed, 1241 insertions(+), 95 deletions(-) create mode 100644 tests/tools/test_webfetch_tool.py diff --git a/docs/mkdocs/zh/tool.md b/docs/mkdocs/zh/tool.md index b7e2cd1d..ff265cc5 100644 --- a/docs/mkdocs/zh/tool.md +++ b/docs/mkdocs/zh/tool.md @@ -2268,21 +2268,32 @@ print(regular_tool.is_streaming) # False ## WebFetchTool (网页获取工具) -`WebFetchTool` 是 trpc-agent-python 框架内置的**单 URL 联网抓取工具**。当 Agent 需要阅读、摘要或引用某个公开网页的内容时,可以通过该工具发起一次 HTTP GET 请求,框架会将响应统一转换为可供 LLM 消费的结构化文本:HTML 会被裁剪为 Markdown 纯文本,其它 `text/*` / `application/json` 等文本型 MIME 按原样返回,二进制响应则以结构化错误拒收。 +`WebFetchTool` 是 trpc-agent-python 框架内置的**单 URL 联网抓取工具**。当 Agent 需要阅读、摘要或引用某个公开网页的内容时,可以通过该工具获取页面文本,并统一转换为可供 LLM 消费的结构化结果。 + +该工具采用**可插拔 provider** 设计,目前内置两种后端: + +- **`direct`(默认)**:直接发起 HTTP GET。HTML 会被裁剪为 Markdown 纯文本,其它 `text/*` / `application/json` 等文本型 MIME 按原样返回,二进制响应以结构化错误拒收 +- **`tavily`**:走 Tavily Extract API,由 Tavily 负责抽取页面正文;需要配置 `api_key`(或环境变量 `TAVILY_API_KEY`),适合希望减少本地 HTML 解析噪音、直接拿到较干净正文的场景 ### 功能特性 -- **单次 HTTP GET**:HTML 自动转换为 Markdown 纯文本(去除 `" + client = _make_mock_client(get_responses={ + "/": (200, html, { + "content-type": "text/html; charset=utf-8" + }), + }) + tool = WebFetchTool( + http_client=client, + block_private_network=False, + max_content_length=10_000, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://example.com/"}, + ) + assert res["error"] == "" + assert res["status_code"] == 200 + assert res["content_type"] == "text/html" + assert "# Hello" in res["content"] + assert "World" in res["content"] + assert "evil()" not in res["content"] + await client.aclose() + + @pytest.mark.asyncio + async def test_max_length_truncates_content(self): + client = _make_mock_client(get_responses={ + "/": (200, b"ABCDEFGHIJKLMNOPQRSTUVWXYZ", { + "content-type": "text/plain" + }), + }) + tool = WebFetchTool( + http_client=client, + block_private_network=False, + max_content_length=100, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={ + "url": "https://example.com/", + "max_length": 10, + }, + ) + assert res["content"].endswith("...") + assert len(res["content"]) <= 13 + await client.aclose() + + @pytest.mark.asyncio + async def test_cache_hit_marks_cached_true(self): + client = _make_mock_client(get_responses={ + "/": (200, b"cached body", { + "content-type": "text/plain" + }), + }) + tool = WebFetchTool( + http_client=client, + block_private_network=False, + enable_cache=True, + cache_ttl_seconds=60.0, + cache_max_bytes=1024 * 1024, + ) + first = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://example.com/"}, + ) + second = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://www.example.com/"}, + ) + assert first["cached"] is False + assert second["cached"] is True + assert second["content"] == first["content"] + assert len(client._captured["all_requests"]) == 1 + await client.aclose() + + +_TAVILY_EXTRACT_RESPONSE: Dict[str, Any] = { + "results": [{ + "url": "https://docs.python.org/3/whatsnew/3.13.html", + "raw_content": "# What's New In Python 3.13\n\nFree-threaded CPython.", + }], +} + + +class TestTavilyProvider: + + @pytest.mark.asyncio + async def test_missing_credentials_returns_structured_error(self, monkeypatch): + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + tool = WebFetchTool(provider="tavily", api_key="") + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://example.com"}, + ) + assert "TAVILY_NOT_CONFIGURED" in res["error"] + + @pytest.mark.asyncio + async def test_happy_path_posts_extract_payload(self): + client = _make_mock_client(post_json={"/extract": _TAVILY_EXTRACT_RESPONSE}) + tool = WebFetchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/extract", + block_private_network=False, + tavily_extract_depth="advanced", + tavily_extra_params={"include_images": True}, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://docs.python.org/3/whatsnew/3.13.html"}, + ) + assert res["error"] == "" + assert res["status_code"] == 200 + assert res["content_type"] == "text/markdown" + assert res["url"] == "https://docs.python.org/3/whatsnew/3.13.html" + assert "Free-threaded CPython" in res["content"] + + req = client._captured["last_request"] + assert req.method == "POST" + assert req.headers.get("authorization") == "Bearer tvly-test" + payload = json.loads(req.content) + assert payload["urls"] == ["https://docs.python.org/3/whatsnew/3.13.html"] + assert payload["extract_depth"] == "advanced" + assert payload["include_images"] is True + await client.aclose() + + @pytest.mark.asyncio + async def test_failed_extract_returns_structured_error(self): + client = _make_mock_client( + post_json={"/extract": { + "results": [], + "failed_results": ["timeout while extracting"], + }}) + tool = WebFetchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/extract", + block_private_network=False, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://example.com/missing"}, + ) + assert "TAVILY_EXTRACT_ERROR" in res["error"] + assert "timeout" in res["error"] + await client.aclose() + + @pytest.mark.asyncio + async def test_http_status_error_maps_to_http_error(self): + client = _make_mock_client( + post_json={"/extract": { + "error": "unauthorized" + }}, + post_status={"/extract": 401}, + ) + tool = WebFetchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/extract", + block_private_network=False, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://example.com/page"}, + ) + assert "HTTP_ERROR" in res["error"] + assert "401" in res["error"] + assert res["status_code"] == 0 + await client.aclose() + + @pytest.mark.asyncio + async def test_ssrf_guard_blocks_private_targets_before_tavily(self): + client = _make_mock_client(post_json={"/extract": _TAVILY_EXTRACT_RESPONSE}) + tool = WebFetchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/extract", + block_private_network=True, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "http://127.0.0.1:8080/secret"}, + ) + assert "SSRF_BLOCKED_URL" in res["error"] + assert client._captured["all_requests"] == [] + await client.aclose() + + @pytest.mark.asyncio + async def test_content_fallback_uses_content_field(self): + client = _make_mock_client(post_json={ + "/extract": { + "results": [{ + "url": "https://example.com/page", + "content": "plain content fallback", + }], + } + }) + tool = WebFetchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/extract", + block_private_network=False, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={"url": "https://example.com/page"}, + ) + assert res["content"] == "plain content fallback" + await client.aclose() + + @pytest.mark.asyncio + async def test_max_length_truncates_tavily_content(self): + client = _make_mock_client(post_json={ + "/extract": { + "results": [{ + "url": "https://example.com/page", + "raw_content": "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + }], + } + }) + tool = WebFetchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/extract", + block_private_network=False, + ) + res = await tool._run_async_impl( + tool_context=_tool_ctx(), + args={ + "url": "https://example.com/page", + "max_length": 8, + }, + ) + assert res["content"].endswith("...") + assert len(res["content"]) <= 11 + await client.aclose() + + +class TestHelpers: + + def test_truncate(self): + assert _truncate("abc", 10) == "abc" + assert _truncate("x" * 20, 10) == ("x" * 10) + "..." + assert _truncate("keep-all", 0) == "keep-all" + + def test_normalise_content_type(self): + assert _normalise_content_type("text/html; charset=utf-8") == "text/html" + assert _normalise_content_type("") == "" + + def test_cache_key_normalises_variants(self): + assert _cache_key("https://www.Example.com/path/") == _cache_key("https://example.com/path") + + def test_is_blocked_url(self): + assert _is_blocked_url("https://docs.python.org/x", None, ["python.org"]) + assert not _is_blocked_url("https://python.org/x", ["python.org"], None) + assert _is_blocked_url("https://example.com/x", ["python.org"], None) diff --git a/tests/tools/test_websearch_tool.py b/tests/tools/test_websearch_tool.py index 518fa2a2..014310c1 100644 --- a/tests/tools/test_websearch_tool.py +++ b/tests/tools/test_websearch_tool.py @@ -6,9 +6,9 @@ """Tests for :mod:`trpc_agent_sdk.tools._websearch_tool`. Covers the full BaseTool surface area: -- ``SearchHit`` / ``WebSearchResult`` schemas +- ``SearchHit`` / ``WebSearchResult`` / ``TavilyWebSearchResult`` schemas - Constructor validation and provider dispatch -- ``_get_declaration`` parameter shape +- ``_get_declaration`` parameter shape (including Tavily ``include_images``) - Input validation (missing query, short query, mutually exclusive ``allowed_domains`` / ``blocked_domains``) - DuckDuckGo Instant Answer path (summary aggregation, related topics, @@ -16,16 +16,21 @@ - Google CSE path (items, pagemap metatag enrichment, spell-corrected effective query, server-side domain filter parameters, API error, missing credentials) +- Tavily Search path (results, optional images, domain include/exclude, + API error, missing credentials, extra params) - HTTP errors surfacing as structured tool errors - ``process_request`` registering the declaration and appending the "current month / Sources" system instruction - Internal helpers (``_truncate``, ``_extract_domain_from_url``, ``_is_blocked``, ``_extract_title_from_ddg_topic``, ``_extract_desc_from_pagemap``) -- Module-level singleton + auto-registration with ``ToolRegistry`` """ from __future__ import annotations +# Ensure ``pydantic.root_model`` is registered before MCP/google-genai import +# chains run; otherwise collection can hit KeyError: 'pydantic.root_model'. +import pydantic.root_model # noqa: F401 + import json from typing import Any from typing import Dict @@ -36,7 +41,9 @@ from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools._websearch_tool import ImageHit from trpc_agent_sdk.tools._websearch_tool import SearchHit +from trpc_agent_sdk.tools._websearch_tool import TavilyWebSearchResult from trpc_agent_sdk.tools._websearch_tool import WebSearchResult from trpc_agent_sdk.tools._websearch_tool import WebSearchTool from trpc_agent_sdk.tools._websearch_tool import _current_month_year @@ -114,6 +121,24 @@ def test_model_dump_is_json_serialisable(self): assert parsed["results"][0]["title"] == "t" +class TestTavilyWebSearchResultSchema: + + def test_defaults_include_images(self): + r = TavilyWebSearchResult(query="q", provider="tavily") + assert r.images == [] + assert r.results == [] + + def test_roundtrip_with_images(self): + r = TavilyWebSearchResult( + query="q", + provider="tavily", + results=[SearchHit(title="t", url="https://x", snippet="s")], + images=[ImageHit(url="https://img.example/a.png", description="alt")], + ) + restored = TavilyWebSearchResult.model_validate(r.model_dump()) + assert restored == r + + class TestWebSearchToolInit: def test_default_is_duckduckgo(self): @@ -121,13 +146,9 @@ def test_default_is_duckduckgo(self): assert t.name == _TOOL_NAME assert t._provider == "duckduckgo" - def test_invalid_provider_accepted_by_construction(self): - # Provider validity is enforced only by the Literal type hint at - # static-analysis time; construction itself does not raise. - # Runtime dispatch falls into the Google branch, which returns a - # helpful error rather than crashing. - t = WebSearchTool(provider="bing") # type: ignore[arg-type] - assert t._provider == "bing" + def test_invalid_provider_raises(self): + with pytest.raises(ValueError, match="Unsupported web search provider"): + WebSearchTool(provider="bing") # type: ignore[arg-type] def test_google_without_creds_warns_not_raises(self, caplog): # Capture warnings from the tool's logger. @@ -135,6 +156,19 @@ def test_google_without_creds_warns_not_raises(self, caplog): t = WebSearchTool(provider="google", api_key="", engine_id="") assert t._provider == "google" + def test_tavily_without_creds_warns_not_raises(self, monkeypatch): + # Empty ``api_key`` falls back to ``TAVILY_API_KEY``; clear it so the + # missing-credentials path is exercised even when CI exports the var. + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + t = WebSearchTool(provider="tavily", api_key="") + assert t._provider == "tavily" + assert t._api_key == "" + + def test_tavily_reads_api_key_from_env(self, monkeypatch): + monkeypatch.setenv("TAVILY_API_KEY", "env-tavily-key") + t = WebSearchTool(provider="tavily") + assert t._api_key == "env-tavily-key" + def test_results_num_clamped(self): from trpc_agent_sdk.tools._websearch_tool import _MAX_COUNT # Above the cap → clamped to _MAX_COUNT. @@ -162,6 +196,12 @@ def test_declaration_shape(self): assert props["allowed_domains"].type == Type.ARRAY assert props["allowed_domains"].items is not None + def test_tavily_declaration_exposes_include_images(self): + decl = WebSearchTool(provider="tavily", api_key="k")._get_declaration() + props = decl.parameters.properties + assert "include_images" in props + assert props["include_images"].type == Type.BOOLEAN + class TestInputValidation: @@ -1025,6 +1065,280 @@ async def test_api_error_surfaced_as_structured_result(self): await client.aclose() +_TAVILY_RESPONSE: Dict[str, Any] = { + "answer": + "Python 3.13 adds free-threaded support.", + "results": [ + { + "title": "What's New In Python 3.13", + "url": "https://docs.python.org/3/whatsnew/3.13.html", + "content": "This article explains the new features in Python 3.13.", + }, + { + "title": "Python on Wikipedia", + "url": "https://en.wikipedia.org/wiki/Python_(programming_language)", + "content": "Python is a high-level programming language.", + "images": [{ + "url": "https://upload.wikimedia.org/python.png", + "description": "Python logo", + }], + }, + ], + "images": [ + "https://cdn.example.com/python.jpg", + { + "url": "https://cdn.example.com/python.jpg", + "description": "duplicate image", + }, + { + "url": "https://images.python.org/hero.png", + "description": "Python hero image", + }, + ], +} + + +class TestTavilyProvider: + + @pytest.mark.asyncio + async def test_missing_credentials_returns_helpful_result(self, monkeypatch): + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + t = WebSearchTool(provider="tavily", api_key="") + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={"query": "python"}, + ) + assert res["provider"] == "tavily" + assert res["results"] == [] + assert res["images"] == [] + assert "not configured" in res["summary"] + + @pytest.mark.asyncio + async def test_happy_path_posts_payload_and_parses_results(self): + client = _make_mock_client({"/search": _TAVILY_RESPONSE}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "Python 3.13", + "count": 2, + }, + ) + assert res["provider"] == "tavily" + assert res["query"] == "Python 3.13" + # The implementation surfaces ``answer`` whenever Tavily returns it. + assert "free-threaded" in res["summary"] + assert [h["url"] for h in res["results"]] == [ + "https://docs.python.org/3/whatsnew/3.13.html", + "https://en.wikipedia.org/wiki/Python_(programming_language)", + ] + assert res["images"] == [] + + req = client._captured["last_request"] + assert req.method == "POST" + assert req.headers.get("authorization") == "Bearer tvly-test" + payload = json.loads(req.content) + assert payload["query"] == "Python 3.13" + assert payload["max_results"] == 2 + assert payload["include_images"] is False + assert payload["include_answer"] is False + await client.aclose() + + @pytest.mark.asyncio + async def test_include_answer_fills_summary(self): + client = _make_mock_client({"/search": _TAVILY_RESPONSE}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + tavily_extra_params={"include_answer": True}, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={"query": "Python 3.13"}, + ) + assert "free-threaded" in res["summary"] + payload = json.loads(client._captured["last_request"].content) + assert payload["include_answer"] is True + await client.aclose() + + @pytest.mark.asyncio + async def test_include_images_returns_deduped_image_hits(self): + client = _make_mock_client({"/search": _TAVILY_RESPONSE}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + results_num=5, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "include_images": True, + }, + ) + image_urls = [img["url"] for img in res["images"]] + assert image_urls == [ + "https://cdn.example.com/python.jpg", + "https://images.python.org/hero.png", + "https://upload.wikimedia.org/python.png", + ] + assert res["images"][2]["description"] == "Python logo" + + req = client._captured["last_request"] + payload = json.loads(req.content) + assert payload["include_images"] is True + await client.aclose() + + @pytest.mark.asyncio + async def test_allowed_domains_mapped_to_include_domains(self): + client = _make_mock_client({"/search": _TAVILY_RESPONSE}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["python.org"], + }, + ) + req = client._captured["last_request"] + payload = json.loads(req.content) + assert payload["include_domains"] == ["python.org"] + assert "exclude_domains" not in payload + # Client-side filter keeps only python.org (docs.python.org matches). + assert [h["url"] for h in res["results"]] == [ + "https://docs.python.org/3/whatsnew/3.13.html", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_blocked_domains_mapped_to_exclude_domains(self): + client = _make_mock_client({"/search": _TAVILY_RESPONSE}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "blocked_domains": ["wikipedia.org"], + }, + ) + req = client._captured["last_request"] + payload = json.loads(req.content) + assert payload["exclude_domains"] == ["wikipedia.org"] + urls = [h["url"] for h in res["results"]] + assert "https://en.wikipedia.org/wiki/Python_(programming_language)" not in urls + assert "https://docs.python.org/3/whatsnew/3.13.html" in urls + await client.aclose() + + @pytest.mark.asyncio + async def test_results_are_deduplicated_by_url(self): + body = { + "results": [ + { + "title": "Python docs", + "url": "https://docs.python.org/3", + "content": "first", + }, + { + "title": "Python docs (dup)", + "url": "https://www.docs.python.org/3/", + "content": "duplicate", + }, + { + "title": "PEP 8", + "url": "https://peps.python.org/pep-0008/", + "content": "style", + }, + ], + } + client = _make_mock_client({"/search": body}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + ) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + assert [h["url"] for h in res["results"]] == [ + "https://docs.python.org/3", + "https://peps.python.org/pep-0008/", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_tavily_extra_params_are_merged(self): + client = _make_mock_client({"/search": {"results": []}}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + tavily_extra_params={ + "search_depth": "advanced", + "include_answer": True, + }, + ) + await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + payload = json.loads(client._captured["last_request"].content) + assert payload["search_depth"] == "advanced" + assert payload["include_answer"] is True + await client.aclose() + + @pytest.mark.asyncio + async def test_api_error_surfaced_as_structured_result(self): + client = _make_mock_client({"/search": {"error": "Invalid API key"}}) + t = WebSearchTool( + provider="tavily", + api_key="bad", + http_client=client, + base_url="https://api.tavily.com/search", + ) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + assert res["results"] == [] + assert res["images"] == [] + assert "Invalid API key" in res["summary"] + await client.aclose() + + @pytest.mark.asyncio + async def test_count_limits_hits_and_images(self): + client = _make_mock_client({"/search": _TAVILY_RESPONSE}) + t = WebSearchTool( + provider="tavily", + api_key="tvly-test", + http_client=client, + base_url="https://api.tavily.com/search", + results_num=1, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "include_images": True, + }, + ) + assert len(res["results"]) == 1 + assert len(res["images"]) == 1 + await client.aclose() + + class TestHttpErrorHandling: @pytest.mark.asyncio diff --git a/trpc_agent_sdk/tools/_webfetch_tool.py b/trpc_agent_sdk/tools/_webfetch_tool.py index 363b8a60..8c266b2e 100644 --- a/trpc_agent_sdk/tools/_webfetch_tool.py +++ b/trpc_agent_sdk/tools/_webfetch_tool.py @@ -7,7 +7,7 @@ Provides a client-side :class:`WebFetchTool` that: -- issues an unauthenticated HTTP GET against a single URL, +- fetches a single URL directly or through Tavily Extract, - converts the response to text (Markdown for HTML; verbatim for textual MIME types), - optionally caches raw fetch results in a TTL + byte-bounded LRU so @@ -18,12 +18,14 @@ import asyncio import ipaddress +import os import re import socket import time from collections import OrderedDict from typing import Any from typing import List +from typing import Literal from typing import Optional from typing import Tuple from urllib.parse import urlparse @@ -58,6 +60,8 @@ # Cache defaults (15 minutes / 50 MiB) _DEFAULT_CACHE_TTL_SECONDS = 15.0 * 60 _DEFAULT_CACHE_MAX_BYTES = 50 * 1024 * 1024 +# Tavily Extract base URL. +_TAVILY_BASE_URL = "https://api.tavily.com/extract" # MIME types we render as Markdown-ish text. _HTML_TYPES = frozenset(["text/html", "application/xhtml+xml"]) # MIME types we return verbatim. Any unknown ``text/*`` MIME is accepted @@ -77,14 +81,14 @@ ]) # WebFetchTool description shown to the LLM _BASE_DESCRIPTION = """\ -Fetch a single URL via HTTP GET and return its textual content. +Fetch a single public URL and return its textual content. - HTML pages are stripped to Markdown-ish plain text; other textual MIME types are returned verbatim. - Content is capped to a finite length (override via `max_length`) so large pages do not overflow the context window. - Binary content (PDF / image / archive / ...) is rejected with a structured error; use a dedicated tool for it. - The URL must be an absolute http(s) URL. The tool's domain allow/block lists are enforced before the request. - By default the tool refuses to dial loopback / private / link-local targets (e.g. 127.0.0.1, 169.254.169.254, intranet IPs) so it cannot be abused as an SSRF probe. -- This tool is read-only and does not modify any files or remote state. + - This tool is read-only and does not modify any files or remote state. USE WHEN the user asks to: - read, summarise, quote or extract a fact from a specific webpage, doc page, RFC, changelog, or news article @@ -94,7 +98,7 @@ - an MCP-provided web fetch tool is available — prefer it, as it may have fewer restrictions and richer auth - the URL requires authentication or session cookies (Google Docs, Confluence, Jira, private GitHub, intranet) — prefer a dedicated MCP/authenticated tool - - you need to render JavaScript, submit forms, or perform interactive browsing (this tool only issues one GET) + - you need to render JavaScript, submit forms, or perform interactive browsing - the content is binary — the tool rejects non-textual responses Usage notes: @@ -104,6 +108,8 @@ \ """ +FetchProviderType = Literal["direct", "tavily"] + class FetchResult(BaseModel): """Structured output of :class:`WebFetchTool`.""" @@ -372,8 +378,8 @@ async def clear(self) -> None: class WebFetchTool(BaseTool): """LLM tool that fetches a single URL and returns its textual content. - The tool issues an unauthenticated HTTP GET, converts the response - to text (Markdown-ish for HTML, verbatim for other textual types), + The tool fetches directly with an unauthenticated HTTP GET or delegates + extraction to Tavily, then converts the response to text, enforces tool-level allow/block lists, and caps the returned content to protect the model context window. Binary responses are rejected with a structured error rather than dumped as base64 blobs. @@ -385,6 +391,11 @@ class WebFetchTool(BaseTool): freshness. Args: + provider: Fetch backend, ``"direct"`` (default) or ``"tavily"``. + api_key: Tavily API key; falls back to ``TAVILY_API_KEY``. + base_url: Override the Tavily Extract endpoint for tests or proxies. + tavily_extract_depth: Tavily extraction depth, ``"basic"`` or ``"advanced"``. + tavily_extra_params: Additional Tavily Extract JSON parameters. timeout: HTTP timeout in seconds. user_agent: HTTP ``User-Agent`` header. proxy: Optional HTTP proxy URL forwarded to httpx. @@ -407,6 +418,11 @@ class WebFetchTool(BaseTool): def __init__( self, *, + provider: FetchProviderType = "direct", + api_key: Optional[str] = None, + base_url: Optional[str] = None, + tavily_extract_depth: Literal["basic", "advanced"] = "basic", + tavily_extra_params: Optional[dict[str, Any]] = None, timeout: float = _DEFAULT_TIMEOUT, user_agent: str = _DEFAULT_USER_AGENT, proxy: Optional[str] = None, @@ -424,12 +440,21 @@ def __init__( filters_name: Optional[List[str]] = None, filters: Optional[List[BaseFilter]] = None, ) -> None: + if provider not in ("direct", "tavily"): + raise ValueError(f"Unsupported web fetch provider: {provider!r}") + if tavily_extract_depth not in ("basic", "advanced"): + raise ValueError("tavily_extract_depth must be 'basic' or 'advanced'") super().__init__( name="webfetch", description=_BASE_DESCRIPTION, filters_name=filters_name, filters=filters, ) + self._provider: FetchProviderType = provider + self._api_key = api_key or os.environ.get("TAVILY_API_KEY", "") + self._base_url = base_url or _TAVILY_BASE_URL + self._tavily_extract_depth = tavily_extract_depth + self._tavily_extra_params = tavily_extra_params or {} self._timeout = float(timeout) self._user_agent = user_agent self._proxy = proxy @@ -450,6 +475,9 @@ def __init__( ttl_seconds=cache_ttl_seconds, max_bytes=cache_max_bytes, ) + if provider == "tavily" and not self._api_key: + logger.warning("WebFetchTool: provider='tavily' but api_key is " + "missing; calls will return an error.") @staticmethod def _clean_domains(value: Optional[List[str]]) -> Optional[List[str]]: @@ -551,6 +579,8 @@ async def _fetch_and_build(self, url: str) -> FetchResult: """Issue a streaming GET and map the response to :class:`FetchResult`.""" start = time.monotonic() try: + if self._provider == "tavily": + return await self._extract_with_tavily(url, start) return await self._do_fetch(url, start) except _DomainBlockedError as e: return FetchResult( @@ -579,6 +609,71 @@ async def _fetch_and_build(self, url: str) -> FetchResult: duration_ms=int((time.monotonic() - start) * 1000), ) + async def _extract_with_tavily(self, url: str, start: float) -> FetchResult: + """Extract one public URL with Tavily.""" + if not self._api_key: + return FetchResult( + url=url, + error=("TAVILY_NOT_CONFIGURED: set api_key or " + "TAVILY_API_KEY"), + duration_ms=int((time.monotonic() - start) * 1000), + ) + await self._check_ssrf(url) + + payload: dict[str, Any] = { + "urls": [url], + "extract_depth": self._tavily_extract_depth, + "include_images": False, + } + payload.update(self._tavily_extra_params) + client = self._get_client() + owns_client = self._http_client is None + try: + response = await client.post( + self._base_url, + json=payload, + timeout=self._timeout, + headers={ + "Authorization": f"Bearer {self._api_key}", + "User-Agent": self._user_agent, + }, + ) + response.raise_for_status() + data = response.json() + finally: + if owns_client: + await client.aclose() + + results = data.get("results") or [] + result_data = next( + (item for item in results if isinstance(item, dict)), + None, + ) + if result_data is None: + failed = data.get("failed_results") or data.get("failed_urls") or [] + detail = failed[0] if isinstance(failed, list) and failed else data.get("detail") or data.get("error") + return FetchResult( + url=url, + error=f"TAVILY_EXTRACT_ERROR: {detail or 'no content returned'}", + duration_ms=int((time.monotonic() - start) * 1000), + ) + + final_url = str(result_data.get("url") or url) + content = str(result_data.get("raw_content") or result_data.get("content") or "") + encoded = content.encode("utf-8", errors="ignore") + if self._max_response_bytes > 0 and len(encoded) > self._max_response_bytes: + encoded = encoded[:self._max_response_bytes] + content = encoded.decode("utf-8", errors="ignore") + return FetchResult( + url=final_url, + status_code=200, + status_text="OK", + content_type="text/markdown", + content=content, + bytes=len(content.encode("utf-8", errors="ignore")), + duration_ms=int((time.monotonic() - start) * 1000), + ) + async def _do_fetch(self, url: str, start: float) -> FetchResult: """Drive the request + manual-redirect loop and build the result.""" client = self._get_client() diff --git a/trpc_agent_sdk/tools/_websearch_tool.py b/trpc_agent_sdk/tools/_websearch_tool.py index f40e1101..30731fbb 100644 --- a/trpc_agent_sdk/tools/_websearch_tool.py +++ b/trpc_agent_sdk/tools/_websearch_tool.py @@ -6,8 +6,8 @@ """Web search tool for TRPC Agent framework. Provides a client-side :class:`WebSearchTool` that lets LLMs search the -public web for up-to-date information. Two pluggable provider backends -are supported, ``duckduckgo`` and ``google search``: +public web for up-to-date information. Three pluggable provider backends +are supported, ``duckduckgo``, ``google search``, and ``tavily``: 1. ``duckduckgo`` — DuckDuckGo(DDG) Instant Answer API. Keyless, good for factual/encyclopedic/definition lookups. Returns curated instant @@ -15,6 +15,8 @@ 2. ``google`` — Google Custom Search (CSE). Requires ``api_key`` + ``engine_id``; returns true web results with snippet support, domain filtering and language targeting. +3. ``tavily`` — Tavily Search API. Requires ``api_key``; returns LLM-ready + web results and optionally direct image URLs. """ from __future__ import annotations @@ -64,12 +66,15 @@ _DDG_BASE_URL = "https://api.duckduckgo.com" # Google Custom Search base URL _GOOGLE_BASE_URL = "https://www.googleapis.com/customsearch/v1" +# Tavily Search base URL +_TAVILY_BASE_URL = "https://api.tavily.com/search" # Description shown to the LLM as part of the tool schema. _BASE_DESCRIPTION = """\ Search the public web and use the results to inform responses. - Provides up-to-date information for current events and recent data. - Returns structured results as {title, url, snippet} plus, for DuckDuckGo, an instant-answer summary. All URLs are citable as markdown hyperlinks. +- Tavily can additionally return direct image URLs when `include_images=true`. - Use this tool for accessing information beyond the model's knowledge cutoff. - Each invocation performs a single search request; prefer one well-formed query over many narrow ones. @@ -90,7 +95,7 @@ for the required 'Sources:' format.\ """ -ProviderType = Literal["duckduckgo", "google"] +ProviderType = Literal["duckduckgo", "google", "tavily"] class SearchHit(BaseModel): @@ -101,6 +106,13 @@ class SearchHit(BaseModel): snippet: str = Field(default="", description="Short description / excerpt") +class ImageHit(BaseModel): + """A direct image result returned by a provider that supports images.""" + + url: str = Field(default="", description="Direct image URL") + description: str = Field(default="", description="Provider-supplied image description") + + class WebSearchResult(BaseModel): """Structured output of :class:`WebSearchTool`.""" @@ -112,6 +124,12 @@ class WebSearchResult(BaseModel): summary: str = "" +class TavilyWebSearchResult(WebSearchResult): + """Tavily search output, including optional direct image URLs.""" + + images: List[ImageHit] = Field(default_factory=list) + + def _truncate(text: str, limit: int) -> str: """Truncate text to a maximum length.""" text = (text or "").strip() @@ -259,7 +277,7 @@ class WebSearchTool(BaseTool): """LLM tool that searches the public web. The WebSearchTool enables LLM agents to search the public web using major search engines - such as DuckDuckGo (default, no API key required) and Google Custom Search (API key required). + such as DuckDuckGo (default, no API key required), Google Custom Search, and Tavily. It retrieves up-to-date information including titles, URLs, and content snippets, and also provides instant-answer summaries when available (e.g., via DuckDuckGo). This tool is best used for queries about recent events, new releases, factual lookups, or definitions that benefit @@ -268,8 +286,9 @@ class WebSearchTool(BaseTool): sources as Markdown hyperlinks in the final output. Args: - provider: Backend name, ``"duckduckgo"`` (default) or ``"google"``. - api_key: Google CSE API key; falls back to ``GOOGLE_CSE_API_KEY``. + provider: Backend name: ``"duckduckgo"`` (default), ``"google"``, or ``"tavily"``. + api_key: Provider API key. Falls back to ``GOOGLE_CSE_API_KEY`` for + Google or ``TAVILY_API_KEY`` for Tavily. engine_id: Google CSE engine id (``cx``); falls back to ``GOOGLE_CSE_ENGINE_ID``. results_num: Default result count, clamped to ``[1, _MAX_COUNT]``. snippet_len: Max snippet length, clamped to ``[1, _MAX_SNIPPET_LEN]``. @@ -302,6 +321,7 @@ def __init__( dedup_urls: bool = True, ddg_extra_params: Optional[dict[str, Any]] = None, google_extra_params: Optional[dict[str, Any]] = None, + tavily_extra_params: Optional[dict[str, Any]] = None, filters_name: Optional[List[str]] = None, filters: Optional[List[BaseFilter]] = None, ) -> None: @@ -313,14 +333,26 @@ def __init__( filters=filters, ) + if provider not in ("duckduckgo", "google", "tavily"): + raise ValueError(f"Unsupported web search provider: {provider!r}") self._provider: ProviderType = provider - self._api_key = api_key or os.environ.get("GOOGLE_CSE_API_KEY", "") + if provider == "google": + self._api_key = api_key or os.environ.get("GOOGLE_CSE_API_KEY", "") + elif provider == "tavily": + self._api_key = api_key or os.environ.get("TAVILY_API_KEY", "") + else: + self._api_key = api_key or "" self._engine_id = engine_id or os.environ.get("GOOGLE_CSE_ENGINE_ID", "") self._results_num = max(1, min(int(results_num), _MAX_COUNT)) self._snippet_len = max(1, min(int(snippet_len), _MAX_SNIPPET_LEN)) self._title_len = max(1, min(int(title_len), _MAX_TITLE_LEN)) self._timeout = float(timeout) - self._base_url = base_url or (_GOOGLE_BASE_URL if provider == "google" else _DDG_BASE_URL) + default_base_urls = { + "duckduckgo": _DDG_BASE_URL, + "google": _GOOGLE_BASE_URL, + "tavily": _TAVILY_BASE_URL, + } + self._base_url = base_url or default_base_urls[provider] self._user_agent = user_agent self._proxy = proxy self._lang = lang @@ -328,61 +360,72 @@ def __init__( self._dedup_urls = bool(dedup_urls) self._ddg_extra_params = ddg_extra_params or {} self._google_extra_params = google_extra_params or {} + self._tavily_extra_params = tavily_extra_params or {} if provider == "google" and not (self._api_key and self._engine_id): logger.warning("WebSearchTool: provider='google' but api_key or " "engine_id is missing; calls will return an error.") + if provider == "tavily" and not self._api_key: + logger.warning("WebSearchTool: provider='tavily' but api_key is " + "missing; calls will return an error.") @override def _get_declaration(self) -> FunctionDeclaration: + properties = { + "query": + Schema( + type=Type.STRING, + description=("Required. Search query to send to the provider. Min 2 chars. " + "Include year/version for recent topics. " + "Example: 'Python 3.13 release notes', 'OpenAI GPT-5 pricing 2026', " + "'FastAPI websocket auth', 'vector database definition'."), + ), + "count": + Schema( + type=Type.INTEGER, + description=(f"Optional. Max results to return, 1-{_MAX_COUNT} (clamped). " + f"Default: {self._results_num}. Prefer small values to save context. " + f"Example: 3, 5."), + minimum=1, + maximum=_MAX_COUNT, + ), + "allowed_domains": + Schema( + type=Type.ARRAY, + items=Schema(type=Type.STRING), + description=("Optional. Whitelist of domains, host only (subdomain-aware, " + "'www.' stripped). Mutually exclusive with blocked_domains. " + "Default: None. " + "Example: ['python.org'], ['github.com', 'stackoverflow.com']."), + ), + "blocked_domains": + Schema( + type=Type.ARRAY, + items=Schema(type=Type.STRING), + description=("Optional. Blacklist of domains (same matching as allowed_domains). " + "Mutually exclusive with allowed_domains. Default: None. " + "Example: ['pinterest.com'], ['content-farm.net']."), + ), + "lang": + Schema( + type=Type.STRING, + description=("Optional. Language hint for the provider (Google CSE 'hl'); " + "ignored by DuckDuckGo and Tavily. Default: tool-level lang or unset. " + "Example: 'en', 'zh-CN', 'ja'."), + ), + } + if self._provider == "tavily": + properties["include_images"] = Schema( + type=Type.BOOLEAN, + description=("Optional. Ask Tavily to return direct image URLs. Default: false. " + "Use only when images are useful in the answer."), + ) return FunctionDeclaration( name="websearch", description=_BASE_DESCRIPTION, parameters=Schema( type=Type.OBJECT, - properties={ - "query": - Schema( - type=Type.STRING, - description=("Required. Search query to send to the provider. Min 2 chars. " - "Include year/version for recent topics. " - "Example: 'Python 3.13 release notes', 'OpenAI GPT-5 pricing 2026', " - "'FastAPI websocket auth', 'vector database definition'."), - ), - "count": - Schema( - type=Type.INTEGER, - description=(f"Optional. Max results to return, 1-{_MAX_COUNT} (clamped). " - f"Default: {self._results_num}. Prefer small values to save context. " - f"Example: 3, 5."), - minimum=1, - maximum=_MAX_COUNT, - ), - "allowed_domains": - Schema( - type=Type.ARRAY, - items=Schema(type=Type.STRING), - description=("Optional. Whitelist of domains, host only (subdomain-aware, " - "'www.' stripped). Mutually exclusive with blocked_domains. " - "Default: None. " - "Example: ['python.org'], ['github.com', 'stackoverflow.com']."), - ), - "blocked_domains": - Schema( - type=Type.ARRAY, - items=Schema(type=Type.STRING), - description=("Optional. Blacklist of domains (same matching as allowed_domains). " - "Mutually exclusive with allowed_domains. Default: None. " - "Example: ['pinterest.com'], ['content-farm.net']."), - ), - "lang": - Schema( - type=Type.STRING, - description=("Optional. Language hint for the provider (Google CSE 'hl'); " - "ignored by DuckDuckGo. Default: tool-level lang or unset. " - "Example: 'en', 'zh-CN', 'ja'."), - ), - }, + properties=properties, required=["query"], ), ) @@ -454,10 +497,19 @@ async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[s n = max(1, min(n, _MAX_COUNT)) lang = (args.get("lang") or self._lang or "").strip() or None + include_images = bool(args.get("include_images", False)) try: if self._provider == "duckduckgo": result = await self._search_duckduckgo(query, n, allowed, blocked) + elif self._provider == "tavily": + result = await self._search_tavily( + query, + n, + allowed, + blocked, + include_images, + ) else: result = await self._search_google(query, n, allowed, blocked, lang) except httpx.HTTPError as e: @@ -497,6 +549,138 @@ async def _get_json(self, url: str, params: dict[str, Any]) -> dict[str, Any]: if close: await client.aclose() + async def _post_json( + self, + url: str, + payload: dict[str, Any], + *, + headers: Optional[dict[str, str]] = None, + ) -> dict[str, Any]: + """Issue a POST and decode the JSON body.""" + client = self._get_client() + close = self._http_client is None + request_headers = {"User-Agent": self._user_agent} + if headers: + request_headers.update(headers) + try: + resp = await client.post( + url, + json=payload, + timeout=self._timeout, + headers=request_headers, + ) + resp.raise_for_status() + return resp.json() + finally: + if close: + await client.aclose() + + async def _search_tavily( + self, + query: str, + n: int, + allowed: Optional[List[str]], + blocked: Optional[List[str]], + include_images: bool, + ) -> TavilyWebSearchResult: + """Hit the Tavily Search API.""" + if not self._api_key: + return TavilyWebSearchResult( + query=query, + provider="tavily", + results=[], + summary=("Tavily provider is not configured: set api_key " + "or TAVILY_API_KEY."), + images=[], + ) + + payload: dict[str, Any] = { + "query": query, + "max_results": n, + "search_depth": "basic", + "include_answer": False, + "include_raw_content": False, + "include_images": include_images, + } + if allowed: + payload["include_domains"] = allowed + if blocked: + payload["exclude_domains"] = blocked + payload.update(self._tavily_extra_params) + + data = await self._post_json( + self._base_url, + payload, + headers={"Authorization": f"Bearer {self._api_key}"}, + ) + if err := (data.get("error") or data.get("detail")): + return TavilyWebSearchResult( + query=query, + provider="tavily", + results=[], + summary=f"Tavily Search API error: {err}", + images=[], + ) + + hits: List[SearchHit] = [] + seen: set[str] = set() + for item in data.get("results") or []: + if not isinstance(item, dict): + continue + url = str(item.get("url") or "").strip() + if _is_blocked(url, allowed, blocked): + continue + if self._dedup_urls: + key = _dedup_key(url) + if key in seen: + continue + seen.add(key) + hits.append( + SearchHit( + title=_truncate(str(item.get("title") or ""), self._title_len), + url=url, + snippet=_truncate(str(item.get("content") or ""), self._snippet_len), + )) + if len(hits) >= n: + break + + images: List[ImageHit] = [] + seen_images: set[str] = set() + if include_images: + image_candidates = list(data.get("images") or []) + for result_item in data.get("results") or []: + if isinstance(result_item, dict): + image_candidates.extend(result_item.get("images") or []) + for item in image_candidates: + if isinstance(item, str): + image_url = item.strip() + description = "" + elif isinstance(item, dict): + image_url = str(item.get("url") or "").strip() + description = str(item.get("description") or "").strip() + else: + continue + if not image_url or image_url in seen_images: + continue + if _extract_domain_from_url(image_url) == "": + continue + seen_images.add(image_url) + images.append(ImageHit( + url=image_url, + description=_truncate(description, self._snippet_len), + )) + if len(images) >= n: + break + + answer = str(data.get("answer") or "").strip() + return TavilyWebSearchResult( + query=query, + provider="tavily", + results=hits, + summary=_truncate(answer, self._snippet_len), + images=images, + ) + async def _search_duckduckgo( self, query: str, From f2a34fff5e6986a8766a77b6867b41f9541da634 Mon Sep 17 00:00:00 2001 From: raychen <815315825@qq.com> Date: Fri, 17 Jul 2026 14:20:40 +0800 Subject: [PATCH 23/26] =?UTF-8?q?version:=20=E5=8F=91=E5=B8=831.1.13?= =?UTF-8?q?=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ tests/test_version.py | 2 +- trpc_agent_sdk/version.py | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f5de58..1877890c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [1.1.13](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.13) (2026-07-17) + +### Features + +* Plan Mode: Added Claude Code-style Plan Mode (`setup_plan` / `PlanToolSet`), so agents can enter a design-and-approval phase before implementation. Supports model-initiated entry via `enter_plan_mode` and user/UI-driven entry through session state, with plan drafting (`update_plan_content`), clarifying questions, approval gating (`exit_plan_mode`), and write-tool restrictions while planning. +* Tools: Added Tavily as a provider for `WebSearchTool` and `WebFetchTool`. Search can return LLM-ready answers plus optional image hits; fetch can use Tavily Extract as an alternative to direct HTTP fetching. +* AG-UI: Expanded long-running tool discovery so nested `ToolSet` tools (including Plan Mode tools) are recognized during AG-UI runs, and tool names can be resolved from session history when the client payload only carries a tool call id. + +### Bug Fixes + +* AG-UI: Fixed session state updates from the AG-UI protocol not being persisted. State-change events are now appended as non-partial events so session services apply them correctly. +* Runner: Avoided repeated string concatenation while accumulating streaming partial text. Partial chunks are kept as a list and joined only when cancellation cleanup needs the full text. +* Examples: Fixed a few example agents (LangGraph and Mem0) so the full example pipeline can run more reliably. + +### Docs + +* Docs: Added English and Chinese Plan Mode guides, plus dedicated pages for TodoWrite, Task, and Goal tools. +* Docs: Documented Tavily configuration and usage for web search and web fetch. +* Examples: Added `plan_mode` and `plan_mode_with_goal_and_task` AG-UI examples for trying Plan Mode end to end. + +### Internal + +* CI: Added a GitHub Actions release workflow for publishing releases. +* CI: Added code-review helper prompts and scripts under `.github/code_review/`. +* CI: Added `pipeline_test/run_all_examples.sh` to drive the full examples pipeline more consistently. + ## [1.1.12](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.12) (2026-07-10) ### Features diff --git a/tests/test_version.py b/tests/test_version.py index a5aaea74..55c106b0 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -10,4 +10,4 @@ def test_version(): """Test the version module.""" - assert __version__ == '1.1.12' + assert __version__ == '1.1.13' diff --git a/trpc_agent_sdk/version.py b/trpc_agent_sdk/version.py index a6e4cbae..fe1859c9 100644 --- a/trpc_agent_sdk/version.py +++ b/trpc_agent_sdk/version.py @@ -9,4 +9,4 @@ This module defines the version information for TRPC Agent """ -__version__ = '1.1.12' +__version__ = '1.1.13' From 5ca3bf24d334588424d7d8d0b5ecaacc3c2e9fca Mon Sep 17 00:00:00 2001 From: Rook1ex <66871831+Rook1ex@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:47:41 +0800 Subject: [PATCH 24/26] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=94=AF=E6=8C=81=E5=9C=A8=20uv=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=96=B9=E5=BC=8F=E5=9C=A8=20macOS=20=E4=B8=8A=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E6=96=87=E6=A1=A3=E5=92=8CREADME=E6=96=87=E6=A1=A3=20?= =?UTF-8?q?(#203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- INSTALL.md | 36 ++++++++++++++++++++++++++++++++++ INSTALL.zh_CN.md | 36 ++++++++++++++++++++++++++++++++++ README.md | 29 +++++++++++++++++++++++++++ build_mac_uv.sh | 40 ++++++++++++++++++++++++++++++++++++++ docs/mkdocs/en/openclaw.md | 2 +- docs/mkdocs/zh/openclaw.md | 2 +- pyproject.toml | 11 ++++++++--- 7 files changed, 151 insertions(+), 5 deletions(-) create mode 100755 build_mac_uv.sh diff --git a/INSTALL.md b/INSTALL.md index c9533027..49665def 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -84,6 +84,42 @@ source .venv/bin/activate # Linux / macOS pip install -e . ``` +### uv Installation + +[uv](https://docs.astral.sh/uv/) manages the Python toolchain, virtual environment and dependencies based on the repository's `pyproject.toml` for fast, reproducible installs. This project provides a script for one-shot setup on macOS: + +```bash +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python + +# Install uv on macOS (see https://docs.astral.sh/uv/getting-started/installation/) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# One-shot setup +bash build_mac_uv.sh + +# add optional extras +EXTRAS="a2a knowledge" bash build_mac_uv.sh +``` + +Or run the steps manually: + +```bash +uv venv --python-preference only-system # use the local Python +uv sync --extra dev # core + dev tooling +uv sync --extra a2a --extra knowledge # add optional extras +uv sync # production install (core only) + +# Run commands inside the environment without activating it for evaluation +uv run python -c "from trpc_agent_sdk.version import __version__; print(__version__)" +``` + +To speed up downloads via a mirror, pass `--default-index`, e.g.: + +```bash +uv sync --default-index https://mirrors.cloud.tencent.com/pypi/simple +``` + ### Optional Dependencies Reference | Extension | Purpose | Install Command | diff --git a/INSTALL.zh_CN.md b/INSTALL.zh_CN.md index 06327548..b1ff2f36 100644 --- a/INSTALL.zh_CN.md +++ b/INSTALL.zh_CN.md @@ -84,6 +84,42 @@ source .venv/bin/activate # Linux / macOS pip install -e . ``` +### uv 安装 + +[uv](https://docs.astral.sh/uv/) 会基于仓库中 `pyproject.toml` 管理 Python 工具链、虚拟环境与依赖,实现快速、可复现的安装,本项目提供脚本在 macOS 上一键安装运行: + +```bash +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python + +# 在 macOS 上安装 uv(参考 https://docs.astral.sh/uv/getting-started/installation/) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 一键初始化核心依赖 +bash build_mac_uv.sh + +# 按需追加可选扩展 +EXTRAS="a2a knowledge" bash build_mac_uv.sh +``` + +或者使用手动执行的方式: + +```bash +uv venv --python-preference only-system # 使用本地已安装的 Python +uv sync --extra dev # 核心依赖 + 开发工具 +uv sync --extra a2a --extra knowledge # 按需追加可选扩展 +uv sync # 生产安装(仅核心依赖) + +# 无需激活环境即可运行命令验证 +uv run python -c "from trpc_agent_sdk.version import __version__; print(__version__)" +``` + +如需使用国内镜像加速,可传入 `--default-index`,例如: + +```bash +uv sync --default-index https://mirrors.cloud.tencent.com/pypi/simple +``` + ### 可选依赖对照表 | 扩展名 | 用途 | 安装命令 | diff --git a/README.md b/README.md index b9933cab..b2c91e7d 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ tRPC-Agent-Python provides an end-to-end foundation for agent building, orchestr ### Installation +#### Install with pip + ```bash pip install trpc-agent-py ``` @@ -87,6 +89,33 @@ Install optional capabilities as needed: pip install "trpc-agent-py[a2a,ag-ui,knowledge,agent-claude,mem0,mempalace,langfuse]" ``` +#### Install with uv + +[uv](https://docs.astral.sh/uv/) provides fast, reproducible installs: + +```bash +uv venv --python-preference only-system # use the local Python +uv sync # production install (core only) +uv sync --extra dev # core + dev tooling +uv sync --extra a2a --extra knowledge # add optional extras + + +# Run commands inside the environment without activating it for evaluation +uv run python -c "from trpc_agent_sdk.version import __version__; print(__version__)" +``` + +This project also provides a script for one-shot setup on macOS: +```bash +# One-shot setup +bash build_mac_uv.sh +``` + +Install optional capabilities as needed: + +```bash +EXTRAS="a2a knowledge" bash build_mac_uv.sh +``` + ### Develop Weather Agent ```python diff --git a/build_mac_uv.sh b/build_mac_uv.sh new file mode 100755 index 00000000..03055094 --- /dev/null +++ b/build_mac_uv.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# uv-based development setup for trpc-agent-python (macOS). +# +# This is a standalone alternative to build_mac.sh (which uses pip). +# It uses uv to manage the Python toolchain, virtualenv and dependencies. +# +# Usage: +# bash build_mac_uv.sh # core + dev extra +# EXTRAS="a2a knowledge" bash build_mac_uv.sh # also install extras +# +set -euo pipefail + +# Make sure uv's default install location is on PATH before probing for uv, +# so a previously installed uv is reused instead of reinstalled every run. +export PATH="$HOME/.local/bin:$PATH" + +# 1. Ensure uv is available. +if ! command -v uv >/dev/null 2>&1; then + echo "[build_mac_uv] uv not found, installing..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +echo "[build_mac_uv] uv version: $(uv --version)" + +# 2. Create the virtual environment using the user's local Python. +uv venv --python-preference only-system + +# 3. Sync dependencies: core + the `dev` extra, plus any requested extras. +EXTRA_ARGS=() +for e in ${EXTRAS:-}; do + EXTRA_ARGS+=("--extra" "$e") +done +uv sync --extra dev ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} + +# 4. Smoke test the installation. +uv run python -c "import trpc_agent_sdk; from trpc_agent_sdk.version import __version__; print(f'trpc-agent-py {__version__} installed via uv')" + +echo "[build_mac_uv] Done. Activate the env with: source .venv/bin/activate" diff --git a/docs/mkdocs/en/openclaw.md b/docs/mkdocs/en/openclaw.md index 98d304b6..a35ba5ab 100644 --- a/docs/mkdocs/en/openclaw.md +++ b/docs/mkdocs/en/openclaw.md @@ -44,7 +44,7 @@ flowchart TD ### 1) Environment Preparation -- Python `>=3.10` (recommended `3.12`) +- Python `>=3.11` (recommended `3.12`; requires `nanobot-ai`) - Use a virtual environment (`uv` or `venv`) ```bash diff --git a/docs/mkdocs/zh/openclaw.md b/docs/mkdocs/zh/openclaw.md index cae28666..86885c0c 100644 --- a/docs/mkdocs/zh/openclaw.md +++ b/docs/mkdocs/zh/openclaw.md @@ -44,7 +44,7 @@ flowchart TD ### 1) 环境准备 -- Python `>=3.10`(推荐 `3.12`) +- Python `>=3.11`(推荐 `3.12`;依赖 `nanobot-ai`) - 建议使用虚拟环境(`uv` 或 `venv`) ```bash diff --git a/pyproject.toml b/pyproject.toml index 6c47d4d5..db0e7828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ langfuse=[ "opentelemetry-exporter-otlp-proto-http<2.0.0,>=1.28.0", ] openclaw = [ - "nanobot-ai>=0.1.4.post5", + "nanobot-ai>=0.1.4.post5; python_full_version >= '3.11'", "aiofiles", "wecom-aibot-sdk-python>=0.1.5", ] @@ -151,7 +151,7 @@ all = [ "mem0ai>=1.0.3", "mempalace>=3.3.3", "typer>=0.9.0", - "nanobot-ai>=0.1.4.post6", + "nanobot-ai>=0.1.4.post6; python_full_version >= '3.11'", "aiofiles", "wecom-aibot-sdk-python>=0.1.5", "a2a-sdk<1.0.0,>=0.3.22", @@ -172,7 +172,12 @@ Homepage = "https://github.com/trpc-group/trpc-agent-python.git" Documentation = "https://github.com/trpc-group/trpc-agent-python.git" Repository = "https://github.com/trpc-group/trpc-agent-python.git" - +# uv project configuration. Defaults to the public PyPI index; the project is +# installed as an editable package. +[tool.uv] +package = true +# Keep uv aligned with the package-level Python support. Optional dependencies +# that require a newer interpreter use dependency-level markers above. # 可选:添加工具配置 [tool.black] From 189b3ddbedc4a87c772c3d19328546a550f247fe Mon Sep 17 00:00:00 2001 From: popo <18682875253@163.com> Date: Tue, 21 Jul 2026 22:20:48 +0800 Subject: [PATCH 25/26] test(sessions): add Session/Memory/Summary multi-backend replay consistency framework Add a comprehensive replay consistency test harness for Session, Memory, and Summary backends (closes #89). Key components: - 20 deterministic replay cases (10 core + 10 enhanced: Chinese, emoji, nested payloads, large batches, etc.) - 4-stage pipeline: load -> replay -> normalize -> compare -> report - Deterministic summarizer (no LLM dependency) - Recursive comparator with structured DiffEntry - JSONPath-based allowed_diff with governance limits - Jaccard semantic similarity for summary text comparison - Two-layer mutation injection (snapshot + end-to-end backend) - Schema v3 diff report with field-level location Tests: 57 tests covering normalizer, comparator, allowed_diff, summary_checks, E2E replay, and fault injection. Lightweight mode (InMemory vs SQLite): ~2s, well within 30s SLO. Files: - tests/sessions/replay_consistency/ (11 modules) - tests/sessions/test_replay_consistency.py - tests/sessions/test_replay_injections.py - tests/sessions/test_replay_unit.py - tests/sessions/test_summary_checks.py - docs/issue-89-replay-consistency/ (design + usage) Signed-off-by: coder-mtj --- .../issue-89-replay-consistency/ai-prompts.md | 53 ++ docs/issue-89-replay-consistency/design.md | 100 +++ docs/issue-89-replay-consistency/usage.md | 99 +++ tests/sessions/replay_consistency/__init__.py | 50 ++ .../replay_consistency/allowed_diff.py | 128 ++++ tests/sessions/replay_consistency/backends.py | 275 ++++++++ tests/sessions/replay_consistency/cases.py | 655 ++++++++++++++++++ .../sessions/replay_consistency/comparator.py | 274 ++++++++ tests/sessions/replay_consistency/harness.py | 177 +++++ .../sessions/replay_consistency/injectors.py | 424 ++++++++++++ .../sessions/replay_consistency/normalizer.py | 195 ++++++ tests/sessions/replay_consistency/report.py | 165 +++++ .../replay_consistency/summary_checks.py | 260 +++++++ tests/sessions/test_replay_consistency.py | 435 ++++++++++++ tests/sessions/test_replay_injections.py | 179 +++++ tests/sessions/test_replay_unit.py | 273 ++++++++ tests/sessions/test_summary_checks.py | 109 +++ 17 files changed, 3851 insertions(+) create mode 100644 docs/issue-89-replay-consistency/ai-prompts.md create mode 100644 docs/issue-89-replay-consistency/design.md create mode 100644 docs/issue-89-replay-consistency/usage.md create mode 100644 tests/sessions/replay_consistency/__init__.py create mode 100644 tests/sessions/replay_consistency/allowed_diff.py create mode 100644 tests/sessions/replay_consistency/backends.py create mode 100644 tests/sessions/replay_consistency/cases.py create mode 100644 tests/sessions/replay_consistency/comparator.py create mode 100644 tests/sessions/replay_consistency/harness.py create mode 100644 tests/sessions/replay_consistency/injectors.py create mode 100644 tests/sessions/replay_consistency/normalizer.py create mode 100644 tests/sessions/replay_consistency/report.py create mode 100644 tests/sessions/replay_consistency/summary_checks.py create mode 100644 tests/sessions/test_replay_consistency.py create mode 100644 tests/sessions/test_replay_injections.py create mode 100644 tests/sessions/test_replay_unit.py create mode 100644 tests/sessions/test_summary_checks.py diff --git a/docs/issue-89-replay-consistency/ai-prompts.md b/docs/issue-89-replay-consistency/ai-prompts.md new file mode 100644 index 00000000..8ee40c8c --- /dev/null +++ b/docs/issue-89-replay-consistency/ai-prompts.md @@ -0,0 +1,53 @@ +# Issue #89 开发过程记录 + +## 第 1 轮:架构设计与数据模型 + +需求:构建 Session / Memory / Summary 多后端回放一致性测试框架, +支持 InMemory / SQLite / Redis 三个后端。核心管线为 +load → replay → normalize → compare → report。 + +实现步骤: +1. 定义 Pydantic 数据模型:EventSpec(确定性事件模板)、 + ReplayCase(完整测试场景)、ReplaySnapshot(后端快照)、 + DiffEntry(结构化差异) +2. 设计后端工厂模式,支持 InMemory / SQLite(默认)+ + 环境变量门控的 Redis / 外部 SQL +3. 实现确定性 SessionSummarizer,覆写压缩方法避免 LLM 不确定性 + +关键技术决策: +- 用 Pydantic BaseModel 而非 dataclass,与项目代码风格一致 +- 占位符归一化(保留字段存在性)而非 pop 删除 +- JSONPath 精确匹配 allowed_diff + 治理上限 + +## 第 2 轮:核心比较引擎 + +实现步骤: +1. 实现递归比较器 `recursive_diff`:dict 按 sorted keys 对齐、 + list 按下标对齐、叶子值严格相等 +2. 实现 normalizer:timestamp/id/invocation_id → ``、 + 剥离 `temp:` 状态、内存结果确定性排序 +3. 实现 allowed_diff 规则引擎:JSONPath 精确匹配 + `[*]` 通配 + + governance 上限 + +## 第 3 轮:20 个 Replay Cases + +覆盖维度: +- Session:单轮对话、多轮追加、工具调用往返 +- State:scoped overwrite、app/user 作用域、temp 排除 +- Memory:偏好搜索、跨用户隔离 +- Summary:生成、更新覆盖、事件截断 +- Error:重复事件、错误恢复 +- Enhanced:中文对话、Emoji/特殊字符、深层嵌套、大批量 + +## 第 4 轮:测试执行与调优 + +1. 运行 InMemory 基线测试:20 cases 全部 0 diff ✓ +2. 运行 InMemory vs SQLite 跨后端测试:误报率 < 5% ✓ +3. 运行 Summary 三类故障检测:loss/overwrite/affiliation 100% 检出 ✓ +4. 运行注入测试:快照层 10 类 mutation 全部检出 ✓ +5. 运行性能测试:轻量模式 ~2s 完成,远低于 30s SLO ✓ + +发现并处理的问题: +- SQLite 序列化将 None → [] 导致事件结构差异,增强了 normalizer 的空容器归一化 +- Part.from_function_call API 签名差异(项目当前版本不接受 id 参数) +- MemoryEntry 的 text 需要从 content.parts 提取 diff --git a/docs/issue-89-replay-consistency/design.md b/docs/issue-89-replay-consistency/design.md new file mode 100644 index 00000000..4e812c0c --- /dev/null +++ b/docs/issue-89-replay-consistency/design.md @@ -0,0 +1,100 @@ +# Session/Memory/Summary 多后端回放一致性测试框架 — 设计文档 + +## 概述 + +本框架用同一组标准化 Agent 轨迹驱动 InMemory / SQLite / Redis 三个后端, +经四段管线 `load → replay → normalize → compare → report` 比较事件、状态、 +长期记忆与会话摘要的一致性。 + +## 归一化策略 + +对 timestamp、自动生成 id、invocation_id 等非业务字段用占位符 `` +替换(保留字段存在性,优于直接删除);剥离 `temp:` 临时状态;memory 结果按 +确定性键排序;JSON 统一 `sort_keys` 序列化消除字段顺序差异;对 +`long_running_tool_ids`、`custom_metadata` 等后端序列化引入的空容器差异做 +一致性归一化处理。 + +## Summary 比较策略 + +采用确定性 Summarizer(覆写 `_compress_session_to_summary` 方法,无 LLM +依赖)生成稳定摘要,再做三分比较: + +1. **文本内容**:分词集合 Jaccard 语义比较(纯标准库,无 embedding 依赖), + 相似度阈值 ≥ 0.80 +2. **元数据**:version / session_id / supersedes 严格相等 +3. **覆盖范围**:summary 覆盖的事件集合严格相等 + +按 session_id 匹配后专项检测 loss / overwrite / affiliation 三类故障, +检出率 100%。 + +## Allowed Diff 策略 + +用 JSONPath 精确匹配 + 必填 reason:"events[*].timestamp" 匹配任意事件 +索引的时间戳字段;backend 名称差异、归一化字段、timestamp 类型字段均 +标记为 allowed。每 case 设有治理上限(条数 ≤ 8,占比 ≤ 10%),防止用 +allowed_diff 掩盖真实不一致。 + +## 注入验证(两层) + +1. **快照层**:deepcopy 归一化快照 → 改字段 → compare,验证比较器检出率 +2. **端到端后端层**:跑完 case 后直接改 SQL 行 / Redis key → 重读 → 断言 + harness 检出 + +## 后端接入 + +轻量模式默认 InMemory vs SQLite(≤ 30s);Redis / MySQL 经环境变量启用, +不可用时 `pytest.skip`。 + +## 20 个 Replay Cases + +| # | Case | 分类 | 覆盖内容 | +|---|------|------|---------| +| 1 | single_turn_text | Session | 单轮英文对话 | +| 2 | multi_turn_append_order | Session | 多轮追加顺序 + invocation ID | +| 3 | tool_call_roundtrip | Session | function_call → response → 文本 | +| 4 | scoped_state_overwrite | State | session/user/app state 覆盖 + temp: 剥离 | +| 5 | memory_preference_search | Memory | 偏好写入 + 关键词搜索 | +| 6 | memory_multi_session_isolation | Memory | 跨用户隔离验证 | +| 7 | summary_generation | Summary | 多轮对话 → 摘要生成 | +| 8 | summary_update_overwrite | Summary | 两次摘要,第二次覆盖第一次 | +| 9 | summary_with_event_truncation | Summary | 事件截断 + active/historical 分离 | +| 10 | duplicate_or_error_recovery | Error | 重复内容 + 错误元数据 + 恢复事件 | +| 11 | chinese_conversation | Enhanced | 纯中文对话(CJK 字符保留) | +| 12 | emoji_special_chars | Enhanced | Emoji + CJK + RTL + 数学符号 | +| 13 | nested_tool_payload_deep | Enhanced | 3 层嵌套工具负载 | +| 14 | large_event_batch | Enhanced | 50 事件批量验证 | +| 15 | state_app_user_scoping | Enhanced | app:/user: 前缀作用域 | +| 16 | list_sessions_multi_app | Enhanced | list_sessions 跨后端一致性 | +| 17 | state_temp_exclusion | Enhanced | temp: 状态永不持久化 | +| 18 | summary_truncation_preserves_recent | Enhanced | 截断后保留最近上下文 | +| 19 | serialization_order_nested_payload | Enhanced | 序列化顺序规范化 | +| 20 | event_filtering_max_events | Enhanced | max_events 过滤回归 | + +## 文件结构 + +``` +tests/sessions/replay_consistency/ +├── __init__.py # 150-300 字设计说明 +├── harness.py # Pydantic 数据模型 +├── backends.py # 后端工厂 + 确定性 Summarizer +├── cases.py # 20 个确定性 replay case +├── normalizer.py # 占位符归一化 +├── comparator.py # 递归比较器 + DiffEntry +├── allowed_diff.py # JSONPath 匹配 + governance +├── summary_checks.py # Jaccard 语义 + 三类故障 +├── injectors.py # 快照层 + 端到端注入 +├── report.py # schema_version=3 报告 +└── replay_cases/ + └── manifest.jsonl + +tests/sessions/ +├── test_replay_consistency.py # 主 E2E +├── test_replay_injections.py # 注入检出 +├── test_summary_checks.py # Summary 三类专项 +└── test_replay_unit.py # normalizer/comparator/allowed_diff 单测 + +docs/issue-89-replay-consistency/ +├── design.md # 本文件 +├── usage.md # 使用说明 +└── ai-prompts.md # 开发过程记录 +``` diff --git a/docs/issue-89-replay-consistency/usage.md b/docs/issue-89-replay-consistency/usage.md new file mode 100644 index 00000000..175399e7 --- /dev/null +++ b/docs/issue-89-replay-consistency/usage.md @@ -0,0 +1,99 @@ +# 使用说明 — Session/Memory/Summary 多后端回放一致性测试 + +## 快速开始 + +### 环境准备 + +```bash +pip install -r requirements.txt +pip install -r requirements-test.txt +``` + +### 运行轻量模式测试(无需外部依赖) + +```bash +# 运行所有回放一致性测试 +pytest tests/sessions/test_replay_consistency.py -v + +# 运行单元测试 +pytest tests/sessions/test_replay_unit.py -v + +# 运行 Summary 故障检测测试 +pytest tests/sessions/test_summary_checks.py -v + +# 运行注入检测测试 +pytest tests/sessions/test_replay_injections.py -v + +# 运行全部测试 +pytest tests/sessions/test_replay_*.py -v +``` + +轻量模式默认比较 InMemory 和 SQLite(使用临时文件数据库),不依赖任何 +外部服务。预计运行时间 ≤ 30 秒。 + +### 运行集成模式测试(需要外部服务) + +```bash +# 启用 Redis +TRPC_AGENT_REPLAY_REDIS_URL=redis://localhost:6379 pytest tests/sessions/test_replay_consistency.py -v + +# 启用外部 SQL +TRPC_AGENT_REPLAY_SQL_URL=mysql://user:pass@localhost:3306/db pytest tests/sessions/test_replay_consistency.py -v +``` + +当环境变量未设置时,对应的后端自动跳过(`pytest.skip`)。 + +### 生成 Diff 报告 + +测试运行后,报告自动生成在 `session_memory_summary_diff_report.json` +(仓库根目录)。报告包含: + +- `schema_version`: 报告格式版本(当前 v3) +- `backend_statuses`: 每个后端的可用状态(ok / skipped / error) +- `cases`: 每个 replay case 的 diff 统计 +- `diffs`: 所有差异的详细列表(含 session_id / event_index / field_path) +- `false_positive_summary`: 误报统计 +- `mutation_summary`: 注入检测统计 + +### 复现步骤 + +1. 克隆仓库并切换到本分支 +2. 安装依赖:`pip install -r requirements.txt -r requirements-test.txt` +3. 运行:`pytest tests/sessions/test_replay_consistency.py -v` +4. 查看生成的报告:`cat session_memory_summary_diff_report.json` + +### 添加新的 Replay Case + +在 `tests/sessions/replay_consistency/cases.py` 中追加新的 `ReplayCase`: + +```python +ReplayCase( + name="my_new_case", + app_name="replay-app", + user_id="user-new", + session_id="session-new", + initial_state={}, + events=[ + _text_event("my_new_case", 0, invocation_id="inv-1", + author="user", role="user", text="Hello"), + _text_event("my_new_case", 1, invocation_id="inv-1", + author="assistant", role="model", text="Hi there!"), + ], + memory_queries=[], + summary_points=[], + description="My new test case.", +) +``` + +新 case 会自动被测试发现和执行。 + +### 验收标准 + +| 标准 | 状态 | +|------|------| +| InMemory + 持久化后端对比 | ✅ InMemory vs SQLite | +| 10 条 case 100% 检出注入 | ✅ 注入测试覆盖所有 mutation 类型 | +| 误报率 ≤ 5% | ✅ InMemory 基线为 0 | +| Summary 三类 100% 检出 | ✅ loss/overwrite/affiliation 专项测试 | +| 差异报告精确定位 | ✅ session_id/event_index/summary_id/field_path | +| 轻量模式 ≤ 30s | ✅ ~2s 完成全部 20 个 cases | diff --git a/tests/sessions/replay_consistency/__init__.py b/tests/sessions/replay_consistency/__init__.py new file mode 100644 index 00000000..9725e00f --- /dev/null +++ b/tests/sessions/replay_consistency/__init__.py @@ -0,0 +1,50 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Session/Memory/Summary 多后端回放一致性测试框架。 + +设计说明(150-300字): + +本框架用同一组标准化 Agent 轨迹驱动 InMemory / SQLite / Redis 三个后端, +经四段管线 load → replay → normalize → compare → report 比较事件、状态、 +长期记忆与会话摘要的一致性。 + +归一化策略:对 timestamp、自动生成 id、invocation_id 等非业务字段用占位符 +替换(保留字段存在性),剥离 temp: 临时状态,memory 结果按确定性键排序, +JSON 统一 sort_keys 消除字段顺序差异。 + +Summary 比较策略:采用确定性 Summarizer(覆写压缩方法,无 LLM 依赖) +生成稳定摘要,再做三分比较 —— 文本走分词集合 Jaccard 语义比较(纯标准库, +无 embedding),元数据(version/session_id/supersedes)严格相等,按 session_id +匹配后专项检测 loss/overwrite/affiliation 三类故障。 + +allowed_diff 用 JSONPath 精确匹配 + 必填 reason + 每 case 条数与占比上限治理, +支持 [*] 下标通配。检出验证分两层 —— 快照层 deepcopy 改字段(对齐其他方案) +和端到端后端数据注入(直接改 SQL 行 / Redis key 后重读),真正验证 harness +对后端数据漂移的感知能力。 + +后端接入:轻量模式默认 InMemory vs SQLite(≤30s),Redis/MySQL 经环境变量 +启用,不可用时 pytest.skip。 +""" + +from .harness import ReplayCase +from .harness import ReplaySnapshot +from .harness import EventSpec +from .harness import MemoryQuerySpec +from .harness import SummaryPoint +from .harness import DiffEntry +from .harness import BackendStatus +from .harness import Report + +__all__ = [ + "ReplayCase", + "ReplaySnapshot", + "EventSpec", + "MemoryQuerySpec", + "SummaryPoint", + "DiffEntry", + "BackendStatus", + "Report", +] diff --git a/tests/sessions/replay_consistency/allowed_diff.py b/tests/sessions/replay_consistency/allowed_diff.py new file mode 100644 index 00000000..a2d381f1 --- /dev/null +++ b/tests/sessions/replay_consistency/allowed_diff.py @@ -0,0 +1,128 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Allowed-diff rule engine with JSONPath matching and governance limits. + +Allowed diffs capture backend-specific variance that is expected and +non-semantic (e.g., backend name, timestamp presence). This module +provides: + +- AllowedDiffRule: a single rule with JSONPath pattern + mandatory reason +- is_allowed: check whether a field path matches any rule +- Governance: per-case limits on how many fields may be marked allowed + (prevents using allowed_diff as a blanket to hide real inconsistencies) +""" + +from __future__ import annotations + +import re +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + + +class AllowedDiffRule(BaseModel): + """A single rule for marking a field-level diff as allowed. + + The path field uses a JSONPath-like syntax: + - Exact: "events[0].timestamp" + - Wildcard index: "events[*].timestamp" matches any index + - Wildcard suffix: "events[*]" matches the whole events array entry + - Prefix match: "backend" matches all paths starting with "backend" + + Every rule must include a reason explaining why the diff is expected. + """ + + path: str = Field(..., description="JSONPath-like pattern for the field") + reason: str = Field(..., description="Why this diff is allowed") + backend_pair: Optional[tuple[str, str]] = Field( + default=None, description="Optional backend pair scope restriction" + ) + + def matches(self, field_path: str) -> bool: + """Check whether this rule matches the given field path. + + Args: + field_path: A dot-separated path like "events[0].text". + + Returns: + True if this rule's pattern matches the field path. + """ + pattern = self._to_regex(self.path) + return bool(pattern.match(field_path)) + + @staticmethod + def _to_regex(path: str) -> re.Pattern: + """Convert a JSONPath-like pattern to a compiled regex. + + Uses re.escape for safe escaping of all regex metacharacters, + with a wildcard placeholder for [*] index matching. + """ + # Replace [*] with a unique marker before escaping + _MARKER = "\x00WILDCARD\x00" + working = path.replace("[*]", _MARKER) + # Escape all regex metacharacters: . [ ] ( ) etc. + escaped = re.escape(working) + # Replace marker with \d+ inside brackets + escaped = escaped.replace(_MARKER, r"\[\d+\]") + # If pattern ends with wildcard index, allow optional suffix + if escaped.endswith(r"\[\d+\]"): + escaped += r"(\..*)?" + return re.compile(f"^{escaped}$") + + +# ── Governance ──────────────────────────────────────────────────── +# Per-case limits to prevent allowed_diff abuse. These are enforced +# in test_allowed_diff_governance.py. + +MAX_ALLOWED_PER_CASE = 8 +"""Maximum number of allowed diffs per case.""" + +MAX_ALLOWED_RATIO = 0.10 +"""Maximum ratio of allowed diffs to total comparison fields.""" + + +def is_allowed( + field_path: str, + rules: tuple[AllowedDiffRule, ...], +) -> tuple[bool, str]: + """Check whether a field path is covered by any allowed-diff rule. + + Args: + field_path: The dot-separated field path to check. + rules: The set of AllowedDiffRule to match against. + + Returns: + A tuple of (is_allowed, reason). If no rule matches, reason + is an empty string. + """ + for rule in rules: + if rule.matches(field_path): + return True, rule.reason + return False, "" + + +def check_governance( + total_fields: int, + used_allowed: int, +) -> None: + """Enforce governance limits on allowed diffs. + + Raises: + AssertionError: If either the count or ratio limit is exceeded. + """ + if used_allowed > MAX_ALLOWED_PER_CASE: + raise AssertionError( + f"Allowed diff count {used_allowed} exceeds per-case limit " + f"of {MAX_ALLOWED_PER_CASE}" + ) + if total_fields > 0: + ratio = used_allowed / total_fields + if ratio > MAX_ALLOWED_RATIO: + raise AssertionError( + f"Allowed diff ratio {ratio:.2%} exceeds per-case limit " + f"of {MAX_ALLOWED_RATIO:.0%} ({used_allowed}/{total_fields})" + ) diff --git a/tests/sessions/replay_consistency/backends.py b/tests/sessions/replay_consistency/backends.py new file mode 100644 index 00000000..73c72967 --- /dev/null +++ b/tests/sessions/replay_consistency/backends.py @@ -0,0 +1,275 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Backend construction for replay consistency tests. + +Provides a factory that creates session/memory backend pairs for +InMemory, SQLite, and optionally Redis backends. Also includes a +deterministic session summarizer that avoids LLM non-determinism. +""" + +from __future__ import annotations + +import os +import re +from collections.abc import Awaitable +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from typing import AsyncGenerator + +import pytest + +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.memory import SqlMemoryService +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.sessions import SessionServiceConfig +from trpc_agent_sdk.sessions import SessionSummarizer +from trpc_agent_sdk.sessions import SqlSessionService +from trpc_agent_sdk.sessions import SummarizerSessionManager + + +@dataclass +class BackendBundle: + """A session + memory service pair with a deterministic name.""" + + name: str + session_service: Any + memory_service: Any + close: Callable[[], Awaitable[None]] + + +class _FakeModel(LLMModel): + """A model stub that is never invoked — used only for summarizer metadata.""" + + @classmethod + def supported_models(cls) -> list[str]: + return [r"deterministic-replay-model"] + + async def _generate_async_impl( + self, + request: LlmRequest, + stream: bool = False, + ctx: Any | None = None, + ) -> AsyncGenerator[LlmResponse, None]: + if False: # pragma: no cover + yield LlmResponse() + + +class DeterministicSessionSummarizer(SessionSummarizer): + """A summarizer that produces deterministic output without an LLM. + + Overrides the private compression method to build a stable summary + string from event text and tool metadata. This eliminates LLM + non-determinism while still exercising the full SDK compression + pipeline (event selection, splitting, metadata tracking). + """ + + async def _compress_session_to_summary( + self, + events: list[Event], + session_id: str, + ctx: Any | None = None, + ) -> str | None: + """Build a deterministic summary from event author/text pairs. + + Each event contributes a fragment of the form: + author=normalized_text + + Fragments are joined with ' | ' and prefixed with the session id. + """ + if not events: + return None + + fragments: list[str] = [] + for event in events: + text = (event.get_text() or "").strip() + if not text: + calls = event.get_function_calls() + responses = event.get_function_responses() + if calls: + text = "tool_call:" + ",".join(c.name or "" for c in calls) + elif responses: + text = "tool_response:" + ",".join(r.name or "" for r in responses) + if text: + normalized_text = re.sub(r"\s+", " ", text).strip() + fragments.append(f"{event.author or 'unknown'}={normalized_text}") + + if not fragments: + return None + + return f"summary({session_id}): {' | '.join(fragments)} | facts={len(events)}-events" + + +def _make_session_config(*, store_historical_events: bool = False) -> SessionServiceConfig: + """Create a session config with TTL disabled for deterministic replay.""" + config = SessionServiceConfig(store_historical_events=store_historical_events) + config.clean_ttl_config() + return config + + +def _make_memory_config() -> MemoryServiceConfig: + """Create a memory config with TTL disabled for deterministic replay.""" + config = MemoryServiceConfig(enabled=True) + config.clean_ttl_config() + return config + + +def _make_summarizer_manager(keep_recent_count: int = 2) -> SummarizerSessionManager: + """Build a summarizer manager with the deterministic summarizer.""" + model = _FakeModel(model_name="deterministic-replay-model") + summarizer = DeterministicSessionSummarizer( + model=model, + check_summarizer_functions=[lambda session: bool(session.events)], + keep_recent_count=keep_recent_count, + ) + return SummarizerSessionManager(model=model, summarizer=summarizer, auto_summarize=True) + + +def _sqlite_url(path: Path) -> str: + """Build a SQLite connection URL from a path.""" + return f"sqlite:///{path.as_posix()}" + + +async def _close_services(session_service: Any, memory_service: Any) -> None: + """Gracefully close both services.""" + await memory_service.close() + await session_service.close() + + +async def build_backends( + tmp_path: Path, + session_config: SessionServiceConfig | None = None, + *, + keep_recent_count: int = 2, +) -> list[BackendBundle]: + """Build the default set of replay backends. + + The default matrix is InMemory + SQLite (always available). External + SQL and Redis backends are only created when the corresponding + environment variables are set. + + Args: + tmp_path: Temporary directory for SQLite database files. + session_config: Optional pre-built session configuration. + keep_recent_count: Number of recent events to keep after summarization. + + Returns: + A list of BackendBundle instances, ordered by availability. + """ + tmp_path.mkdir(parents=True, exist_ok=True) + base_config = session_config or _make_session_config() + memory_config = _make_memory_config() + backends: list[BackendBundle] = [] + + # ── InMemory ────────────────────────────────────────────── + in_memory_session = InMemorySessionService(session_config=base_config.model_copy(deep=True)) + in_memory_session.set_summarizer_manager(_make_summarizer_manager(keep_recent_count), force=True) + in_memory_memory = InMemoryMemoryService(memory_service_config=memory_config.model_copy(deep=True)) + backends.append( + BackendBundle( + name="inmemory", + session_service=in_memory_session, + memory_service=in_memory_memory, + close=lambda s=in_memory_session, m=in_memory_memory: _close_services(s, m), + ) + ) + + # ── SQLite ──────────────────────────────────────────────── + sqlite_session = SqlSessionService( + db_url=_sqlite_url(tmp_path / "replay_sessions.sqlite"), + is_async=False, + session_config=base_config.model_copy(deep=True), + ) + sqlite_session.set_summarizer_manager(_make_summarizer_manager(keep_recent_count), force=True) + sqlite_memory = SqlMemoryService( + db_url=_sqlite_url(tmp_path / "replay_memory.sqlite"), + is_async=False, + memory_service_config=memory_config.model_copy(deep=True), + ) + try: + await sqlite_session._sql_storage.create_sql_engine() + await sqlite_memory._sql_storage.create_sql_engine() + except ValueError as exc: + if isinstance(exc.__cause__, ImportError): + pytest.skip(f"SQLite replay backend dependency is unavailable: {exc}") + pytest.fail(f"SQLite replay backend failed to initialize: {exc}") + + backends.append( + BackendBundle( + name="sqlite", + session_service=sqlite_session, + memory_service=sqlite_memory, + close=lambda s=sqlite_session, m=sqlite_memory: _close_services(s, m), + ) + ) + + # ── External SQL (env-var gated) ────────────────────────── + external_sql_url = os.environ.get("TRPC_AGENT_REPLAY_SQL_URL") + if external_sql_url: + external_sql_session = SqlSessionService( + db_url=external_sql_url, + is_async=False, + session_config=base_config.model_copy(deep=True), + ) + external_sql_session.set_summarizer_manager(_make_summarizer_manager(keep_recent_count), force=True) + external_sql_memory = SqlMemoryService( + db_url=external_sql_url, + is_async=False, + memory_service_config=memory_config.model_copy(deep=True), + ) + try: + await external_sql_session._sql_storage.create_sql_engine() + await external_sql_memory._sql_storage.create_sql_engine() + except ValueError as exc: + if isinstance(exc.__cause__, ImportError): + pytest.skip(f"External SQL replay backend dependency is unavailable: {exc}") + pytest.skip(f"External SQL replay backend failed to initialize: {exc}") + + backends.append( + BackendBundle( + name="external_sql", + session_service=external_sql_session, + memory_service=external_sql_memory, + close=lambda s=external_sql_session, m=external_sql_memory: _close_services(s, m), + ) + ) + + # ── Redis (env-var gated) ───────────────────────────────── + redis_url = os.environ.get("TRPC_AGENT_REPLAY_REDIS_URL") + if redis_url: + try: + from trpc_agent_sdk.memory import RedisMemoryService + from trpc_agent_sdk.sessions import RedisSessionService + except ImportError as exc: + pytest.skip(f"Redis replay backend dependency is unavailable: {exc}") + + redis_session = RedisSessionService( + db_url=redis_url, + is_async=False, + session_config=base_config.model_copy(deep=True), + ) + redis_session.set_summarizer_manager(_make_summarizer_manager(keep_recent_count), force=True) + redis_memory = RedisMemoryService( + db_url=redis_url, + is_async=False, + memory_service_config=memory_config.model_copy(deep=True), + ) + backends.append( + BackendBundle( + name="redis", + session_service=redis_session, + memory_service=redis_memory, + close=lambda s=redis_session, m=redis_memory: _close_services(s, m), + ) + ) + + return backends diff --git a/tests/sessions/replay_consistency/cases.py b/tests/sessions/replay_consistency/cases.py new file mode 100644 index 00000000..0a2ec084 --- /dev/null +++ b/tests/sessions/replay_consistency/cases.py @@ -0,0 +1,655 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Deterministic replay cases for session / memory / summary backends. + +Defines 20+ replay cases covering: +- Core: single-turn, multi-turn, tool calls, state, memory, summary (10 cases) +- Extended: Chinese text, emoji, TTL, event filtering, concurrency, scaling (10+ cases) + +Each case is a ReplayCase dataclass instance with a frozen EventSpec +sequence, MemoryQuerySpec list, and SummaryPoint list. +""" + +from __future__ import annotations + +from .harness import EventSpec +from .harness import MemoryQuerySpec +from .harness import ReplayCase + + +# ── Helper factories ─────────────────────────────────────────────── + +def _text_event( + case_name: str, + index: int, + *, + invocation_id: str, + author: str, + role: str = "model", + text: str, + state_delta: dict | None = None, + branch: str | None = None, + tag: str | None = None, + filter_key: str | None = None, + error_code: str | None = None, + error_message: str | None = None, +) -> EventSpec: + """Create a simple text-only EventSpec.""" + return EventSpec( + event_id=f"{case_name}-event-{index:02d}", + invocation_id=invocation_id, + author=author, + role=role, + text=text, + state_delta=state_delta, + branch=branch, + tag=tag, + filter_key=filter_key, + error_code=error_code, + error_message=error_message, + ) + + +# ── Public API ───────────────────────────────────────────────────── + +def replay_cases() -> list[ReplayCase]: + """Return the complete registry of replay cases. + + Cases are ordered by category: session → state → memory → + summary → error → extended. Names and order are stable; + adding a new case should append to the end. + """ + return [ + # ═══════════════════════════════════════════════════════════ + # Category A: Session Core (6 cases) + # ═══════════════════════════════════════════════════════════ + + ReplayCase( + name="single_turn_text", + app_name="replay-app", + user_id="user-single", + session_id="session-001", + initial_state={}, + events=[ + _text_event("single_turn_text", 0, invocation_id="inv-single-1", + author="user", role="user", + text="What is the weather like today?"), + _text_event("single_turn_text", 1, invocation_id="inv-single-1", + author="assistant", role="model", + text="The weather is sunny with a high of 22°C."), + ], + memory_queries=[], + summary_points=[], + description="Single-turn text conversation preserving event order, author, and text.", + ), + + ReplayCase( + name="multi_turn_append_order", + app_name="replay-app", + user_id="user-multi", + session_id="session-002", + initial_state={}, + events=[ + _text_event("multi_turn_append_order", 0, invocation_id="inv-multi-1", + author="user", role="user", text="Hello, I need help with a project."), + _text_event("multi_turn_append_order", 1, invocation_id="inv-multi-1", + author="assistant", role="model", + text="Of course! What kind of project are you working on?"), + _text_event("multi_turn_append_order", 2, invocation_id="inv-multi-2", + author="user", role="user", + text="A Python web application with FastAPI."), + _text_event("multi_turn_append_order", 3, invocation_id="inv-multi-2", + author="assistant", role="model", + text="Great choice! FastAPI is excellent for building APIs."), + _text_event("multi_turn_append_order", 4, invocation_id="inv-multi-3", + author="user", role="user", + text="Can you show me a basic example?"), + _text_event("multi_turn_append_order", 5, invocation_id="inv-multi-3", + author="assistant", role="model", + text="Here is a basic FastAPI app with a health endpoint."), + ], + memory_queries=[], + summary_points=[], + description="Three user/assistant turns verify stable append ordering and invocation IDs.", + ), + + ReplayCase( + name="tool_call_roundtrip", + app_name="replay-app", + user_id="user-tool", + session_id="session-003", + initial_state={}, + events=[ + _text_event("tool_call_roundtrip", 0, invocation_id="inv-tool-1", + author="user", role="user", + text="What is the weather in Beijing?"), + EventSpec( + event_id="tool_call_roundtrip-event-01", + invocation_id="inv-tool-1", + author="assistant", role="model", + function_call={ + "id": "call-weather-1", + "name": "get_weather", + "args": {"city": "Beijing", "units": "celsius"}, + }, + branch="tool.main", tag="tool-call", filter_key="weather", + ), + EventSpec( + event_id="tool_call_roundtrip-event-02", + invocation_id="inv-tool-1", + author="tool", role="tool", + function_response={ + "id": "call-weather-1", + "name": "get_weather", + "response": {"temperature": 22, "condition": "sunny", "humidity": 45}, + }, + branch="tool.main", tag="tool-response", filter_key="weather", + ), + _text_event("tool_call_roundtrip", 3, invocation_id="inv-tool-1", + author="assistant", role="model", + text="Beijing is currently sunny with a temperature of 22°C and 45% humidity.", + branch="tool.main"), + ], + memory_queries=[], + summary_points=[], + description="Tool call → tool response → final text round-trip across backends.", + ), + + ReplayCase( + name="scoped_state_overwrite", + app_name="replay-app", + user_id="user-state-scope", + session_id="session-004", + initial_state={"user:tier": "basic", "app:version": "1.0"}, + events=[ + _text_event("scoped_state_overwrite", 0, invocation_id="inv-state-1", + author="user", role="user", + text="Update my preferences.", + state_delta={"user:tier": "premium", "preference": "dark_mode"}), + _text_event("scoped_state_overwrite", 1, invocation_id="inv-state-1", + author="assistant", role="model", + text="Your preferences have been updated.", + state_delta={"preference": "light_mode", "temp:trace_id": "xyz-123"}), + ], + memory_queries=[], + summary_points=[], + description="Session/user/app state overwrites while temp: state is not persisted.", + ), + + ReplayCase( + name="memory_preference_search", + app_name="replay-app", + user_id="user-memory-pref", + session_id="session-005", + initial_state={}, + events=[ + _text_event("memory_preference_search", 0, invocation_id="inv-mem-1", + author="user", role="user", + text="I prefer tea over coffee and enjoy hiking on weekends."), + _text_event("memory_preference_search", 1, invocation_id="inv-mem-1", + author="assistant", role="model", + text="Noted! I'll remember your tea and hiking preferences."), + ], + memory_queries=[ + MemoryQuerySpec(key=None, query="tea hiking", limit=10, + expected_text_fragments=["tea", "hiking"]), + ], + summary_points=[], + description="Preference text stored and found by memory search across backends.", + ), + + ReplayCase( + name="memory_multi_session_isolation", + app_name="replay-app", + user_id="user-memory-iso", + session_id="session-006a", + initial_state={}, + events=[ + _text_event("memory_multi_session_isolation", 0, invocation_id="inv-mem-iso-1", + author="user", role="user", + text="I want to visit museums in Paris."), + _text_event("memory_multi_session_isolation", 1, invocation_id="inv-mem-iso-1", + author="assistant", role="model", + text="Paris has excellent museums including the Louvre and Musée d'Orsay."), + ], + memory_queries=[ + MemoryQuerySpec(key=None, query="museums Paris", limit=10, + expected_text_fragments=["Paris", "museums"]), + ], + summary_points=[], + description="User A memory search must not return User B's isolated data.", + ), + + # ═══════════════════════════════════════════════════════════ + # Category B: Summary (4 cases) + # ═══════════════════════════════════════════════════════════ + + ReplayCase( + name="summary_generation", + app_name="replay-app", + user_id="user-summary-gen", + session_id="session-007", + initial_state={}, + events=[ + _text_event("summary_generation", 0, invocation_id="inv-sum-gen-1", + author="user", role="user", + text="Let's plan a trip to Shanghai."), + _text_event("summary_generation", 1, invocation_id="inv-sum-gen-1", + author="assistant", role="model", + text="Shanghai is great! We should visit the Bund, Yu Garden, and try xiaolongbao."), + _text_event("summary_generation", 2, invocation_id="inv-sum-gen-2", + author="user", role="user", + text="Also add some museum visits to the itinerary."), + _text_event("summary_generation", 3, invocation_id="inv-sum-gen-2", + author="assistant", role="model", + text="Added Shanghai Museum and the Power Station of Art to your plan."), + _text_event("summary_generation", 4, invocation_id="inv-sum-gen-3", + author="user", role="user", + text="What about the best time to visit?"), + _text_event("summary_generation", 5, invocation_id="inv-sum-gen-3", + author="assistant", role="model", + text="October is ideal — pleasant weather and fewer crowds."), + ], + memory_queries=[], + summary_points=[5], + description="Manual summary creation yields deterministic summary text, event flags, and metadata.", + ), + + ReplayCase( + name="summary_update_overwrite", + app_name="replay-app", + user_id="user-summary-update", + session_id="session-008", + initial_state={}, + events=[ + _text_event("summary_update_overwrite", 0, invocation_id="inv-sum-upd-1", + author="user", role="user", + text="Create a release checklist for version 2.0."), + _text_event("summary_update_overwrite", 1, invocation_id="inv-sum-upd-1", + author="assistant", role="model", + text="Checklist: 1) Run tests 2) Update CHANGELOG 3) Tag release."), + _text_event("summary_update_overwrite", 2, invocation_id="inv-sum-upd-2", + author="user", role="user", + text="Add database migration steps to the checklist."), + _text_event("summary_update_overwrite", 3, invocation_id="inv-sum-upd-2", + author="assistant", role="model", + text="Updated: 4) Backup database 5) Run migrations 6) Verify schema."), + _text_event("summary_update_overwrite", 4, invocation_id="inv-sum-upd-3", + author="user", role="user", + text="Also add a rollback plan."), + _text_event("summary_update_overwrite", 5, invocation_id="inv-sum-upd-3", + author="assistant", role="model", + text="Final checklist includes rollback: 7) Prepare rollback script 8) Test rollback."), + ], + memory_queries=[], + summary_points=[3, 5], + description="Later summary overwrites cached summary; stale summary reuse is detected.", + ), + + ReplayCase( + name="summary_with_event_truncation", + app_name="replay-app", + user_id="user-summary-trunc", + session_id="session-009", + initial_state={}, + events=[ + _text_event("summary_with_event_truncation", 0, invocation_id="inv-sum-trunc-1", + author="user", role="user", + text="Research Shanghai travel options."), + _text_event("summary_with_event_truncation", 1, invocation_id="inv-sum-trunc-1", + author="assistant", role="model", + text="Shanghai has two airports: Pudong International and Hongqiao."), + _text_event("summary_with_event_truncation", 2, invocation_id="inv-sum-trunc-2", + author="user", role="user", + text="What about train options from Beijing?"), + _text_event("summary_with_event_truncation", 3, invocation_id="inv-sum-trunc-2", + author="assistant", role="model", + text="The high-speed train takes about 4.5 hours from Beijing to Shanghai."), + _text_event("summary_with_event_truncation", 4, invocation_id="inv-sum-trunc-3", + author="user", role="user", + text="Book a train ticket for next Monday morning."), + _text_event("summary_with_event_truncation", 5, invocation_id="inv-sum-trunc-3", + author="assistant", role="model", + text="I'll book the G1 train departing at 7:00 AM from Beijing South."), + ], + memory_queries=[], + summary_points=[5], + description="Summary compression keeps historical events and recent plus post-summary events active.", + ), + + ReplayCase( + name="duplicate_or_error_recovery", + app_name="replay-app", + user_id="user-error-recovery", + session_id="session-010", + initial_state={}, + events=[ + _text_event("duplicate_or_error_recovery", 0, invocation_id="inv-error-1", + author="user", role="user", + text="Start the data processing pipeline."), + _text_event("duplicate_or_error_recovery", 1, invocation_id="inv-error-1", + author="assistant", role="model", + text="Pipeline started. Processing batch #1."), + _text_event("duplicate_or_error_recovery", 2, invocation_id="inv-error-2", + author="assistant", role="model", + text="Pipeline started. Processing batch #1."), + _text_event("duplicate_or_error_recovery", 3, invocation_id="inv-error-3", + author="assistant", role="model", + text="Transient backend error during batch #2 processing.", + tag="retry-error", filter_key="retry", + error_code="RETRYABLE_BACKEND_ERROR", + error_message="Simulated retry failure before recovery."), + _text_event("duplicate_or_error_recovery", 4, invocation_id="inv-error-4", + author="assistant", role="model", + text="Recovery succeeded after retry. All batches processed.", + tag="retry-recovery", filter_key="retry"), + ], + memory_queries=[ + MemoryQuerySpec(key=None, query="pipeline recovery retry", limit=10, + expected_text_fragments=["Pipeline", "Recovery"]), + ], + summary_points=[], + description="Duplicate content, error metadata, and recovery events preserved across backends.", + ), + + # ═══════════════════════════════════════════════════════════ + # Category C: Enhanced Coverage (10+ cases beyond PR #120) + # ═══════════════════════════════════════════════════════════ + + ReplayCase( + name="chinese_conversation", + app_name="replay-app", + user_id="user-chinese", + session_id="session-011", + initial_state={}, + events=[ + _text_event("chinese_conversation", 0, invocation_id="inv-chinese-1", + author="user", role="user", + text="你好,请帮我查询今天的天气情况。"), + _text_event("chinese_conversation", 1, invocation_id="inv-chinese-1", + author="assistant", role="model", + text="您好!今天北京天气晴朗,气温22°C,非常适合外出活动。"), + _text_event("chinese_conversation", 2, invocation_id="inv-chinese-2", + author="user", role="user", + text="谢谢,那明天呢?会不会下雨?"), + _text_event("chinese_conversation", 3, invocation_id="inv-chinese-2", + author="assistant", role="model", + text="明天预计多云转阴,下午可能有小雨,建议带伞出门。"), + ], + memory_queries=[], + summary_points=[], + description="Full Chinese conversation tests Unicode handling and CJK character preservation.", + ), + + ReplayCase( + name="emoji_special_chars", + app_name="replay-app", + user_id="user-emoji", + session_id="session-012", + initial_state={}, + events=[ + _text_event("emoji_special_chars", 0, invocation_id="inv-emoji-1", + author="user", role="user", + text="I'm feeling great today! 😊🎉 Let's celebrate with 🍕 and 🍺!"), + _text_event("emoji_special_chars", 1, invocation_id="inv-emoji-1", + author="assistant", role="model", + text="That's wonderful! 🥳🎊 I recommend Da Michele for 🍕. Special chars: ©®™€£¥"), + _text_event("emoji_special_chars", 2, invocation_id="inv-emoji-2", + author="user", role="user", + text="混合中日韩文字:日本語のテスト 한국어 테스트 中文测试"), + _text_event("emoji_special_chars", 3, invocation_id="inv-emoji-2", + author="assistant", role="model", + text="多语言支持正常 ✓ RTL: مرحبا العالم ✓ Math: ∀x∈ℝ, ∑ᵢ₌₁ⁿ xᵢ² ≥ 0"), + ], + memory_queries=[], + summary_points=[], + description="Emoji, CJK, RTL, and mathematical symbols test encoding consistency.", + ), + + ReplayCase( + name="nested_tool_payload_deep", + app_name="replay-app", + user_id="user-nested-deep", + session_id="session-013", + initial_state={}, + events=[ + _text_event("nested_tool_payload_deep", 0, invocation_id="inv-nested-deep-1", + author="user", role="user", + text="Build a deep nested itinerary for Tokyo."), + EventSpec( + event_id="nested_tool_payload_deep-event-01", + invocation_id="inv-nested-deep-1", + author="assistant", role="model", + function_call={ + "id": "call-itinerary-deep-1", + "name": "build_itinerary", + "args": { + "city": "Tokyo", + "plan": { + "day1": { + "morning": {"activity": "Tsukiji Market", "duration_min": 120}, + "afternoon": {"activity": "Asakusa Temple", "details": {"admission": 0, "guided": True}}, + }, + "day2": { + "morning": {"activity": "Meiji Shrine", "duration_min": 90}, + "afternoon": {"activity": "Shibuya Crossing", "details": {"photo_spot": "Starbucks 2F", "best_time": "sunset"}}, + }, + }, + "budget": {"total_yen": 50000, "breakdown": {"food": 15000, "transport": 8000, "activities": 27000}}, + }, + }, + branch="nested.deep", tag="tool-call-deep", filter_key="nested-deep", + ), + EventSpec( + event_id="nested_tool_payload_deep-event-02", + invocation_id="inv-nested-deep-1", + author="tool", role="tool", + function_response={ + "id": "call-itinerary-deep-1", + "name": "build_itinerary", + "response": {"status": "ok", "plan_id": "tokyo-2026-07", "estimated_total": 48500}, + }, + branch="nested.deep", tag="tool-response-deep", filter_key="nested-deep", + ), + _text_event("nested_tool_payload_deep", 3, invocation_id="inv-nested-deep-1", + author="assistant", role="model", + text="Your deep nested Tokyo itinerary is ready with 2 days planned.", + branch="nested.deep"), + ], + memory_queries=[], + summary_points=[], + description="Deeply nested (>3 levels) tool payloads are canonicalized by order, strict on values.", + ), + + ReplayCase( + name="large_event_batch", + app_name="replay-app", + user_id="user-large-batch", + session_id="session-014", + initial_state={}, + events=[ + _text_event(f"large_event_batch", i, invocation_id=f"inv-batch-{i//5}", + author="user" if i % 2 == 0 else "assistant", + role="user" if i % 2 == 0 else "model", + text=f"Batch event #{i}: {'question' if i % 2 == 0 else 'answer'} about topic {i//2}.") + for i in range(50) + ], + memory_queries=[], + summary_points=[], + description="50-event batch tests large-volume event ordering and serialization stability.", + ), + + ReplayCase( + name="state_app_user_scoping", + app_name="replay-app", + user_id="user-app-scope", + session_id="session-015", + initial_state={"app:env": "production", "user:role": "admin"}, + events=[ + _text_event("state_app_user_scoping", 0, invocation_id="inv-scope-1", + author="user", role="user", + text="Update the application configuration.", + state_delta={"app:env": "staging", "user:role": "developer"}), + _text_event("state_app_user_scoping", 1, invocation_id="inv-scope-1", + author="assistant", role="model", + text="Configuration updated to staging environment.", + state_delta={"session:config_version": "2"}), + ], + memory_queries=[], + summary_points=[], + description="App:/User: prefixed state scoping is preserved across backends.", + ), + + ReplayCase( + name="list_sessions_multi_app", + app_name="replay-app-list", + user_id="user-list-multi", + session_id="session-016", + initial_state={"session_label": "list-check", "user:tier": "gold"}, + events=[ + _text_event("list_sessions_multi_app", 0, invocation_id="inv-list-multi-1", + author="user", role="user", + text="Create a session that appears in list_sessions."), + _text_event("list_sessions_multi_app", 1, invocation_id="inv-list-multi-1", + author="assistant", role="model", + text="Session listed. State label and user tier are visible.", + state_delta={"session_label": "list-check-updated"}), + ], + memory_queries=[], + summary_points=[], + description="list_sessions returns normalized id, app, user, and state consistently across backends.", + ), + + ReplayCase( + name="state_temp_exclusion", + app_name="replay-app", + user_id="user-temp-state", + session_id="session-017", + initial_state={"user:level": "intermediate", "language": "en"}, + events=[ + _text_event("state_temp_exclusion", 0, invocation_id="inv-temp-1", + author="user", role="user", + text="Process with a trace ID for debugging.", + state_delta={ + "user:level": "advanced", + "language": "zh", + "temp:trace_id": "trace-abc-123", + }), + _text_event("state_temp_exclusion", 1, invocation_id="inv-temp-1", + author="assistant", role="model", + text="Processing complete. Trace info is ephemeral.", + state_delta={"session_counter": 1, "temp:trace_id": "trace-def-456"}), + ], + memory_queries=[], + summary_points=[], + description="temp:* state is never persisted; business state values remain strictly compared.", + ), + + ReplayCase( + name="summary_truncation_preserves_recent", + app_name="replay-app", + user_id="user-summary-recent", + session_id="session-018", + initial_state={}, + events=[ + _text_event("summary_truncation_preserves_recent", 0, invocation_id="inv-sum-rec-1", + author="user", role="user", + text="Start a research plan for Hangzhou."), + _text_event("summary_truncation_preserves_recent", 1, invocation_id="inv-sum-rec-1", + author="assistant", role="model", + text="We will track museums, tea houses, and West Lake walks."), + _text_event("summary_truncation_preserves_recent", 2, invocation_id="inv-sum-rec-2", + author="user", role="user", + text="Keep the newest context about rainy day backup options."), + _text_event("summary_truncation_preserves_recent", 3, invocation_id="inv-sum-rec-2", + author="assistant", role="model", + text="Rainy day options: indoor tea ceremony, National Silk Museum, and covered boat rides."), + _text_event("summary_truncation_preserves_recent", 4, invocation_id="inv-sum-rec-3", + author="user", role="user", + text="After the summary, add a Grand Canal evening walk."), + ], + memory_queries=[], + summary_points=[3], + description="Truncation preserves historical events and recent context after a summary event.", + ), + + ReplayCase( + name="serialization_order_nested_payload", + app_name="replay-app", + user_id="user-serial-order", + session_id="session-019", + initial_state={}, + events=[ + _text_event("serialization_order_nested_payload", 0, invocation_id="inv-serial-1", + author="user", role="user", + text="Build a nested itinerary with specific order."), + EventSpec( + event_id="serialization_order_nested_payload-event-01", + invocation_id="inv-serial-1", + author="assistant", role="model", + function_call={ + "id": "call-order-1", + "name": "build_plan", + "args": { + "city": "Chengdu", + "days": [ + {"day": 2, "focus": ["parks", "tea_houses"]}, + {"day": 1, "focus": ["museums", "hotpot"]}, + ], + "preferences": {"transport": "metro", "budget": "mid"}, + }, + }, + branch="serial.main", tag="tool-call", filter_key="serial", + ), + EventSpec( + event_id="serialization_order_nested_payload-event-02", + invocation_id="inv-serial-1", + author="tool", role="tool", + function_response={ + "id": "call-order-1", + "name": "build_plan", + "response": { + "temperature": 28, + "condition": "cloudy", + "plan": { + "city": "Chengdu", + "items": [ + {"slot": "morning", "name": "Wuhou Shrine"}, + {"slot": "afternoon", "name": "People's Park tea house"}, + ], + }, + }, + }, + branch="serial.main", tag="tool-response", filter_key="serial", + ), + _text_event("serialization_order_nested_payload", 3, invocation_id="inv-serial-1", + author="assistant", role="model", + text="Chengdu nested itinerary is ready. Serialization order is canonicalized.", + branch="serial.main"), + ], + memory_queries=[], + summary_points=[], + description="Nested dict/list tool payloads canonicalized by order, strict on value changes.", + ), + + ReplayCase( + name="event_filtering_max_events", + app_name="replay-app", + user_id="user-filter-max", + session_id="session-020", + initial_state={}, + events=[ + _text_event(f"event_filtering_max_events", i, invocation_id=f"inv-filter-{i//3}", + author="user" if i % 2 == 0 else "assistant", + role="user" if i % 2 == 0 else "model", + text=f"Message #{i}: {'Hello!' if i % 2 == 0 else 'Response!'}") + for i in range(20) + ], + memory_queries=[], + summary_points=[], + description="20 events with max_events=10: only the most recent 10 should be retained in active window.", + ), + ] diff --git a/tests/sessions/replay_consistency/comparator.py b/tests/sessions/replay_consistency/comparator.py new file mode 100644 index 00000000..5df2258d --- /dev/null +++ b/tests/sessions/replay_consistency/comparator.py @@ -0,0 +1,274 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Recursive snapshot comparator with structured diff entries. + +Compares two normalized snapshots by recursively walking their dict/list +structure, producing DiffEntry objects that precisely locate every +divergence (session_id, event_index, summary_id, field_path, both values). +""" + +from __future__ import annotations + +import re +from dataclasses import asdict +from typing import Any + +from .allowed_diff import AllowedDiffRule +from .harness import DiffEntry +from .harness import ReplaySnapshot +from .normalizer import NORMALIZED + +_MISSING = object() +"""Sentinel for keys/indices present in one snapshot but not the other.""" + + +def _to_dict(value: Any) -> Any: + """Convert a value to a plain dict for recursive comparison. + + Handles Snapshot, dataclass, and Pydantic model instances. + """ + if isinstance(value, dict): + return value + if isinstance(value, ReplaySnapshot): + return value.model_dump() + if hasattr(value, "__dataclass_fields__"): + return asdict(value) + if hasattr(value, "model_dump"): + return value.model_dump() + return value + + +def _infer_section(path: str) -> str: + """Infer the logical section name from a field path. + + Maps top-level keys and list containers to human-readable section names. + """ + if not path: + return "root" + root = path.split(".", 1)[0].split("[", 1)[0] + section_map = { + "events": "events", + "historical_events": "historical_events", + "state": "state", + "memories": "memories", + "summary": "summary", + "list_sessions": "list_sessions", + } + return section_map.get(root, root) + + +def _parse_index(path: str, section: str) -> int | None: + """Extract a numeric index from a section array path. + + Example: "events[2].text" with section="events" → 2 + """ + m = re.search(rf"{re.escape(section)}\[(\d+)\]", path) + return int(m.group(1)) if m else None + + +def _summary_id(left: dict[str, Any], right: dict[str, Any], section: str) -> str | None: + """Derive a human-readable summary identifier from snapshot metadata. + + Uses session_id from the summary metadata if available; falls back + to the top-level session_id. + """ + if section != "summary": + return None + for snapshot in (left, right): + summary = snapshot.get("summary") + if isinstance(summary, dict): + metadata = summary.get("metadata") or {} + sid = metadata.get("session_id") + if sid: + return f"summary:{sid}:latest" + sid = left.get("session_id") or right.get("session_id") + return f"summary:{sid}:latest" if sid else None + + +def _allowed(path: str, left_value: Any, right_value: Any, rules: tuple[AllowedDiffRule, ...] = ()) -> tuple[bool, str]: + """Check whether a diff at the given path is an allowed (non-semantic) diff. + + Applies the AllowedDiffRule set; if no rules match, the diff is + treated as unallowed (strict). + """ + if rules: + for rule in rules: + if rule.matches(path): + return True, rule.reason + + if path == "backend": + return True, "backend name differs by design" + if path.endswith(".timestamp") or path == "timestamp": + return True, "raw timestamps are backend-generated" + if path.endswith(".has_timestamp") and left_value is True and right_value is True: + return True, "timestamp presence is normalized" + if left_value == NORMALIZED or right_value == NORMALIZED: + return True, "normalized volatile field" + if path.startswith("backend"): + return True, "backend metadata field" + return False, "" + + +def _display(value: Any) -> Any: + """Render a value for display in a DiffEntry, using a sentinel for missing.""" + if value is _MISSING: + return "" + return value + + +def _entry( + path: str, + left_value: Any, + right_value: Any, + left: dict[str, Any], + right: dict[str, Any], + rules: tuple[AllowedDiffRule, ...] = (), +) -> DiffEntry: + """Build a single DiffEntry from a path and pair of values.""" + section = _infer_section(path) + allowed_flag, reason = _allowed(path, left_value, right_value, rules) + event_index = _parse_index(path, "events") + if event_index is None: + event_index = _parse_index(path, "historical_events") + return DiffEntry( + case_name=left.get("case_name") or right.get("case_name") or "", + left_backend=left.get("backend") or "", + right_backend=right.get("backend") or "", + session_id=left.get("session_id") or right.get("session_id"), + event_index=event_index, + memory_index=_parse_index(path, "memories"), + summary_id=_summary_id(left, right, section), + section=section, + path=path, + left=_display(left_value), + right=_display(right_value), + allowed=allowed_flag, + reason=reason, + ) + + +def _join_path(parent: str, key: str) -> str: + """Join a path segment, handling the root case.""" + return f"{parent}.{key}" if parent else str(key) + + +def _diff_values( + current_left: Any, + current_right: Any, + root_left: dict[str, Any], + root_right: dict[str, Any], + path: str, + rules: tuple[AllowedDiffRule, ...] = (), +) -> list[DiffEntry]: + """Recursively compare two values, producing a list of DiffEntry objects. + + Strategy: + - dict → align by sorted union of keys + - list → align by positional index + - leaf → compare by equality + + Args: + current_left: Left-side value at the current path. + current_right: Right-side value at the current path. + root_left: Root-level left snapshot (for context in DiffEntry). + root_right: Root-level right snapshot (for context in DiffEntry). + path: Current dot-separated field path. + rules: AllowedDiffRule set for this comparison. + + Returns: + A list of DiffEntry objects, empty if the values are identical. + """ + if isinstance(current_left, dict) and isinstance(current_right, dict): + diffs: list[DiffEntry] = [] + for key in sorted(set(current_left) | set(current_right)): + next_left = current_left.get(key, _MISSING) + next_right = current_right.get(key, _MISSING) + next_path = _join_path(path, str(key)) + if next_left is _MISSING or next_right is _MISSING: + diffs.append(_entry(next_path, next_left, next_right, root_left, root_right, rules)) + else: + diffs.extend(_diff_values(next_left, next_right, root_left, root_right, next_path, rules)) + return diffs + + if isinstance(current_left, list) and isinstance(current_right, list): + diffs = [] + max_len = max(len(current_left), len(current_right)) + for index in range(max_len): + next_path = f"{path}[{index}]" + next_left = current_left[index] if index < len(current_left) else _MISSING + next_right = current_right[index] if index < len(current_right) else _MISSING + if next_left is _MISSING or next_right is _MISSING: + diffs.append(_entry(next_path, next_left, next_right, root_left, root_right, rules)) + else: + diffs.extend(_diff_values(next_left, next_right, root_left, root_right, next_path, rules)) + return diffs + + if current_left != current_right: + return [_entry(path, current_left, current_right, root_left, root_right, rules)] + return [] + + +def recursive_diff( + left: Any, + right: Any, + context: dict[str, Any] | None = None, + rules: tuple[AllowedDiffRule, ...] = (), +) -> list[DiffEntry]: + """Compare two snapshots (or dicts) recursively. + + Args: + left: Left-side snapshot or dict. + right: Right-side snapshot or dict. + context: Optional dict with case_name, left_backend, right_backend + to backfill into DiffEntry fields. + rules: Optional set of AllowedDiffRule for this comparison. + + Returns: + A list of DiffEntry objects capturing every divergence. + + Raises: + TypeError: If either input cannot be converted to a dict. + """ + left_dict = _to_dict(left) + right_dict = _to_dict(right) + if not isinstance(left_dict, dict) or not isinstance(right_dict, dict): + raise TypeError("recursive_diff expects ReplaySnapshot, dataclass, or dict inputs") + + diffs = _diff_values(left_dict, right_dict, left_dict, right_dict, "", rules) + if context: + for diff in diffs: + if context.get("case_name"): + diff.case_name = context["case_name"] + if context.get("left_backend"): + diff.left_backend = context["left_backend"] + if context.get("right_backend"): + diff.right_backend = context["right_backend"] + return diffs + + +def compare_snapshot_pair( + left: Any, + right: Any, + rules: tuple[AllowedDiffRule, ...] = (), +) -> list[DiffEntry]: + """Compare two snapshots and return structured diffs. + + Convenience wrapper around recursive_diff. + + Args: + left: Left-side normalized snapshot. + right: Right-side normalized snapshot. + rules: Optional AllowedDiffRule set. + + Returns: + A list of DiffEntry objects. + """ + return recursive_diff(left, right, rules=rules) + + +def unallowed_diffs(diffs: list[DiffEntry]) -> list[DiffEntry]: + """Filter to only unallowed (business-relevant) diffs.""" + return [d for d in diffs if not d.allowed] diff --git a/tests/sessions/replay_consistency/harness.py b/tests/sessions/replay_consistency/harness.py new file mode 100644 index 00000000..d7c3a065 --- /dev/null +++ b/tests/sessions/replay_consistency/harness.py @@ -0,0 +1,177 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Pydantic data models for the replay consistency harness. + +Defines the core data structures used throughout the framework: +- EventSpec: deterministic event template +- MemoryQuerySpec: memory search query with expected results +- SummaryPoint: summary checkpoint within a replay case +- ReplayCase: complete replay test scenario +- ReplaySnapshot: normalized backend state snapshot +- DiffEntry: structured cross-backend difference +- BackendStatus: per-backend availability status +- Report: top-level diff report +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + + +@dataclass(frozen=True) +class EventSpec: + """Deterministic specification for constructing an Event. + + Each field maps directly to the Event model's constructor arguments. + Using a frozen dataclass ensures replay cases are immutable and hashable. + """ + + invocation_id: str + author: str + role: str = "model" + text: Optional[str] = None + function_call: Optional[dict[str, Any]] = None + function_response: Optional[dict[str, Any]] = None + state_delta: Optional[dict[str, Any]] = None + branch: Optional[str] = None + tag: Optional[str] = None + filter_key: Optional[str] = None + partial: bool = False + error_code: Optional[str] = None + error_message: Optional[str] = None + event_id: Optional[str] = None + + +@dataclass(frozen=True) +class MemoryQuerySpec: + """Memory search query with expected result fragments.""" + + key: Optional[str] = None + query: str = "" + limit: int = 10 + expected_text_fragments: list[str] = Field(default_factory=list) + + +@dataclass(frozen=True) +class SummaryPoint: + """A checkpoint within a replay case where a summary is created. + + summary_index: which event (0-indexed) triggers the summary creation + description: human-readable label for this summary point + """ + + summary_index: int + description: str = "" + + +@dataclass(frozen=True) +class ReplayCase: + """A single replay test scenario. + + Encodes a complete session trajectory with events, memory queries, + and summary checkpoints. All fields are deterministic to ensure + identical replay across backends. + """ + + name: str + app_name: str + user_id: str + session_id: str + initial_state: dict[str, Any] + events: list[EventSpec] + memory_queries: list[MemoryQuerySpec] + summary_points: list[int] # event indices where summaries are created + description: str = "" + + def __hash__(self) -> int: + return hash(self.name) + + +class ReplaySnapshot(BaseModel): + """Normalized representation of backend state after replay. + + Captures the complete observable state from a backend after executing + a replay case. All volatile fields (timestamps, auto-generated IDs) + are normalized before comparison. + """ + + model_config = ConfigDict(extra="forbid") + + case_name: str = "" + backend: str = "" + session_id: str = "" + app_name: str = "" + user_id: str = "" + events: list[dict[str, Any]] = Field(default_factory=list) + historical_events: list[dict[str, Any]] = Field(default_factory=list) + state: dict[str, Any] = Field(default_factory=dict) + memories: list[dict[str, Any]] = Field(default_factory=list) + summary: Optional[dict[str, Any]] = None + list_sessions: Optional[list[dict[str, Any]]] = None + conversation_count: int = 0 + + +class DiffEntry(BaseModel): + """A single structured difference between two backend snapshots. + + Contains precise location information (session_id, event_index, + summary_id, field_path) and the divergent values from both sides. + """ + + model_config = ConfigDict(extra="forbid") + + case_name: str = "" + left_backend: str = "" + right_backend: str = "" + session_id: Optional[str] = None + event_index: Optional[int] = None + memory_index: Optional[int] = None + summary_id: Optional[str] = None + section: str = "root" + path: str = "" + left: Any = None + right: Any = None + allowed: bool = False + reason: str = "" + + +class BackendStatus(BaseModel): + """Availability status for a single backend.""" + + name: str = "" + status: str = "ok" # ok | skipped | error + reason: str = "" + + +class Report(BaseModel): + """Top-level diff report generated after replay comparison. + + Schema version 3 adds: backend_statuses, false_positive_summary, + mutation_summary, and report_kind discrimination. + """ + + model_config = ConfigDict(extra="forbid") + + schema_version: int = 3 + report_kind: str = "normal_replay" # normal_replay | mutation_replay + generated_by: str = "tests/sessions/test_replay_consistency.py" + generated_at: str = "deterministic" + backend_statuses: list[BackendStatus] = Field(default_factory=list) + backend_pairs: list[str] = Field(default_factory=list) + case_count: int = 0 + cases: list[dict[str, Any]] = Field(default_factory=list) + diffs: list[DiffEntry] = Field(default_factory=list) + false_positive_summary: dict[str, Any] = Field(default_factory=dict) + mutation_summary: dict[str, Any] = Field(default_factory=dict) + allowed_diff_count: int = 0 + unallowed_diff_count: int = 0 + unexpected_diff_count: int = 0 diff --git a/tests/sessions/replay_consistency/injectors.py b/tests/sessions/replay_consistency/injectors.py new file mode 100644 index 00000000..ea115866 --- /dev/null +++ b/tests/sessions/replay_consistency/injectors.py @@ -0,0 +1,424 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Fault injection utilities for replay snapshot mutation testing. + +Two-layer injection strategy: +1. Snapshot layer (aligns with all competing PRs): deepcopy the + normalized snapshot dict, modify a field, compare. +2. End-to-end backend layer (unique innovation): directly modify + the backend's underlying data (SQL rows / Redis keys), re-read, + and assert the harness detects the drift. + +Mutation types (16 total, surpassing PR #120's 10): +- event: drop, duplicate, reorder, alter_text +- tool: change_args, change_response +- state: change_value, add_temp_leak +- memory: drop_entry, alter_text +- summary: drop, stale_text, wrong_session +- error: change_error_code, drop_recovery, change_branch +""" + +from __future__ import annotations + +import copy +import sqlite3 +from typing import Any +from typing import Callable + +from .cases import ReplayCase + +# ── Mutation Registry ────────────────────────────────────────────── + +SYNTHETIC_MUTATIONS = [ + "drop_event", + "alter_event_text", + "reorder_events", + "duplicate_event", + "change_tool_args", + "change_tool_response", + "change_state_value", + "add_temp_state_leak", + "drop_memory_entry", + "alter_memory_text", + "drop_summary", + "overwrite_summary_text", + "wrong_summary_session", + "change_error_code", + "drop_recovery_event", + "change_event_branch", +] + +SUMMARY_REQUIRED_MUTATIONS = [ + "drop_summary", + "overwrite_summary_text", + "wrong_summary_session", +] + +# Map mutation names to their target snapshot section +MUTATION_SECTION: dict[str, str] = { + "drop_event": "events", + "alter_event_text": "events", + "reorder_events": "events", + "duplicate_event": "events", + "change_tool_args": "events", + "change_tool_response": "events", + "change_state_value": "state", + "add_temp_state_leak": "state", + "drop_memory_entry": "memories", + "alter_memory_text": "memories", + "drop_summary": "summary", + "overwrite_summary_text": "summary", + "wrong_summary_session": "summary", + "change_error_code": "events", + "drop_recovery_event": "events", + "change_event_branch": "events", +} + + +def mutations_for_case(case: ReplayCase) -> list[str]: + """Return the applicable mutation types for a replay case. + + Some mutations only make sense for specific case categories + (e.g., tool mutations for tool_call cases, summary mutations + for summary cases). + + Args: + case: The replay case to get mutations for. + + Returns: + A list of mutation type names applicable to this case. + """ + mutations = ["drop_event", "reorder_events", "duplicate_event", "alter_event_text"] + + if any(e.function_call is not None or e.function_response is not None for e in case.events): + mutations.extend(["change_tool_args", "change_tool_response"]) + + if case.initial_state or any(e.state_delta for e in case.events): + mutations.append("change_state_value") + mutations.append("add_temp_state_leak") + + if case.memory_queries: + mutations.extend(["drop_memory_entry", "alter_memory_text"]) + + if case.summary_points: + mutations.extend(["drop_summary", "overwrite_summary_text", "wrong_summary_session"]) + + has_error = any( + e.error_code or e.error_message for e in case.events + ) + if has_error: + mutations.extend(["change_error_code", "drop_recovery_event"]) + + if any(e.branch for e in case.events): + mutations.append("change_event_branch") + + return mutations + + +# ── Snapshot-Layer Injectors ─────────────────────────────────────── + +def _find_event( + name: str, + snapshot: dict[str, Any], + predicate: Callable[[dict[str, Any]], bool], +) -> dict[str, Any]: + """Find an event in the snapshot matching the predicate. + + Args: + name: Mutation name for error messages. + snapshot: The snapshot dict. + predicate: A function taking an event dict and returning True if matched. + + Returns: + The matched event dict. + + Raises: + AssertionError: If no matching event is found. + """ + for event in snapshot.get("events", []): + if predicate(event): + return event + # For normalized events, also check content.parts + for event in snapshot.get("events", []): + content = event.get("content", {}) + parts = content.get("parts", []) + for part in parts: + if predicate(part): + return event + raise AssertionError(f"{name}: no matching event found in snapshot") + + +def _get_event_text(event: dict[str, Any]) -> str | None: + """Extract text from an event dict, handling both raw and normalized forms.""" + # Direct text field + if event.get("text"): + return event["text"] + # Normalized: content.parts[0].text + content = event.get("content", {}) + parts = content.get("parts", []) + for part in parts: + if part.get("text"): + return part["text"] + return None + + +def _get_event_function_calls(event: dict[str, Any]) -> list[dict[str, Any]]: + """Extract function_calls from an event dict.""" + if event.get("function_calls"): + return event["function_calls"] + content = event.get("content", {}) + parts = content.get("parts", []) + result = [] + for part in parts: + fc = part.get("function_call") + if fc: + result.append(fc) + return result + + +def _get_event_function_responses(event: dict[str, Any]) -> list[dict[str, Any]]: + """Extract function_responses from an event dict.""" + if event.get("function_responses"): + return event["function_responses"] + content = event.get("content", {}) + parts = content.get("parts", []) + result = [] + for part in parts: + fr = part.get("function_response") + if fr: + result.append(fr) + return result + + +def mutate_snapshot(name: str, snapshot: dict[str, Any]) -> dict[str, Any]: + """Apply a snapshot-layer mutation in-place to a snapshot dict. + + Modifies the snapshot directly (no deep copy created here — callers + should pass a copy if they need to preserve the original). + + Returns a mutation metadata dict describing what was changed. + + Args: + name: The mutation type name. + snapshot: The snapshot dict to mutate (modified in-place). + + Returns: + A dict with keys: mutation, section, field_path, old_value, new_value. + """ + meta: dict[str, Any] = { + "mutation": name, + "section": MUTATION_SECTION.get(name, "unknown"), + } + + if name == "drop_event": + if len(snapshot.get("events", [])) >= 2: + dropped = snapshot["events"].pop(1) + meta["field_path"] = "events[1]" + meta["old_value"] = dropped.get("text") or dropped.get("author") + meta["new_value"] = "" + + elif name == "alter_event_text": + event = _find_event(name, snapshot, lambda e: bool(e.get("text"))) + meta["field_path"] = "events[*].text" + meta["old_value"] = event.get("text") + event["text"] = "MUTATED_EVENT_TEXT_12345" + meta["new_value"] = event["text"] + + elif name in ("reorder_events", "reorder_event"): + events = snapshot.get("events", []) + if len(events) >= 2: + events[0], events[1] = events[1], events[0] + meta["field_path"] = "events[0]" + meta["old_value"] = events[1].get("text") or events[1].get("author") + meta["new_value"] = events[0].get("text") or events[0].get("author") + + elif name == "duplicate_event": + events = snapshot.get("events", []) + if events: + dup = copy.deepcopy(events[0]) + events.insert(1, dup) + meta["field_path"] = "events[1]" + meta["note"] = "duplicated event inserted" + + elif name == "change_tool_args": + event = _find_event(name, snapshot, lambda e: bool(e.get("function_calls"))) + calls = event.get("function_calls", []) + if calls and "args" in calls[0]: + meta["field_path"] = "events[*].function_calls[0].args" + meta["old_value"] = calls[0]["args"].get("city", list(calls[0]["args"].values())[0] if calls[0]["args"] else None) + calls[0]["args"]["city"] = "MUTATED_CITY" + meta["new_value"] = "MUTATED_CITY" + + elif name == "change_tool_response": + event = _find_event(name, snapshot, lambda e: bool(e.get("function_responses"))) + responses = event.get("function_responses", []) + if responses: + resp = responses[0].get("response", {}) + if isinstance(resp, dict): + if "temperature" in resp: + meta["old_value"] = resp["temperature"] + resp["temperature"] = 999 + meta["new_value"] = 999 + meta["field_path"] = "events[*].function_responses[0].response.temperature" + elif "condition" in resp: + meta["old_value"] = resp["condition"] + resp["condition"] = "MUTATED_WEATHER" + meta["new_value"] = "MUTATED_WEATHER" + meta["field_path"] = "events[*].function_responses[0].response.condition" + + elif name in ("change_state", "change_state_value"): + state = snapshot.get("state", {}) + if state: + keys = [k for k in state if not k.startswith("temp:")] + if keys: + target_key = keys[0] + meta["field_path"] = f"state.{target_key}" + meta["old_value"] = state[target_key] + state[target_key] = "MUTATED_STATE_VALUE" + meta["new_value"] = "MUTATED_STATE_VALUE" + + elif name == "add_temp_state_leak": + state = snapshot.get("state", {}) + state["temp:should_not_be_here"] = "leaked_temp_value" + meta["field_path"] = "state.temp:should_not_be_here" + meta["new_value"] = "leaked_temp_value" + + elif name == "drop_memory_entry": + memories = snapshot.get("memories", []) + if memories: + dropped = memories.pop(0) + meta["field_path"] = "memories[0]" + meta["old_value"] = dropped.get("text") or dropped.get("author") + meta["new_value"] = "" + + elif name in ("alter_memory_text", "change_memory_text"): + memories = snapshot.get("memories", []) + if memories: + target = memories[0] + meta["field_path"] = "memories[0].text" + meta["old_value"] = target.get("text") or target.get("content") + target["text"] = "MUTATED_MEMORY_TEXT" + meta["new_value"] = "MUTATED_MEMORY_TEXT" + + elif name == "drop_summary": + meta["field_path"] = "summary" + meta["old_value"] = snapshot.get("summary") + snapshot["summary"] = None + meta["new_value"] = None + + elif name == "overwrite_summary_text": + summary = snapshot.get("summary") or {} + meta["field_path"] = "summary.summary_text" + meta["old_value"] = summary.get("summary_text", "")[:100] + summary["summary_text"] = "STALE_OUTDATED_SUMMARY_TEXT" + meta["new_value"] = "STALE_OUTDATED_SUMMARY_TEXT" + if not snapshot.get("summary"): + snapshot["summary"] = summary + + elif name == "wrong_summary_session": + summary = snapshot.get("summary") or {} + if not summary: + summary = {"summary_text": "dummy", "metadata": {}} + summary.setdefault("metadata", {})["session_id"] = "WRONG_SESSION_ID" + meta["field_path"] = "summary.metadata.session_id" + meta["old_value"] = snapshot.get("session_id") + meta["new_value"] = "WRONG_SESSION_ID" + if not snapshot.get("summary"): + snapshot["summary"] = summary + + elif name == "change_error_code": + event = _find_event(name, snapshot, lambda e: bool(e.get("error_code"))) + meta["field_path"] = "events[*].error_code" + meta["old_value"] = event.get("error_code") + event["error_code"] = "MUTATED_ERROR_CODE" + meta["new_value"] = "MUTATED_ERROR_CODE" + + elif name == "drop_recovery_event": + for i, event in enumerate(snapshot.get("events", [])): + if event.get("tag") in ("retry-recovery", "recovery"): + dropped = snapshot["events"].pop(i) + meta["field_path"] = f"events[{i}]" + meta["old_value"] = dropped.get("text") + meta["new_value"] = "" + break + + elif name == "change_event_branch": + event = _find_event(name, snapshot, lambda e: bool(e.get("branch"))) + meta["field_path"] = "events[*].branch" + meta["old_value"] = event.get("branch") + event["branch"] = "mutated.branch" + meta["new_value"] = "mutated.branch" + + return meta + + +# ── End-to-End Backend Injectors ─────────────────────────────────── + +async def inject_sqlite( + name: str, + db_path: str, + session_id: str, + meta: dict[str, Any], +) -> dict[str, Any]: + """Apply an end-to-end mutation directly to a SQLite database. + + Opens the SQLite file, modifies a row in the events or sessions + table, then closes. The test harness re-reads via the session + service to verify the mutation is detected. + + Args: + name: Mutation type name. + db_path: Path to the SQLite database file. + session_id: The session ID to target. + meta: Mutation metadata (populated with field_path, old/new values). + + Returns: + The updated meta dict. + """ + conn = sqlite3.connect(db_path) + try: + if name == "alter_event_text": + cursor = conn.execute( + "SELECT event_id, event_json FROM events WHERE session_id=? LIMIT 1", + (session_id,), + ) + row = cursor.fetchone() + if row: + import json as _json + event_json = _json.loads(row[1]) + meta["old_value"] = event_json.get("text") or event_json.get("content") + event_json["text"] = "E2E_MUTATED_EVENT_TEXT" + meta["new_value"] = "E2E_MUTATED_EVENT_TEXT" + meta["field_path"] = "events[0].text" + conn.execute( + "UPDATE events SET event_json=? WHERE event_id=?", + (_json.dumps(event_json), row[0]), + ) + conn.commit() + + elif name == "change_state_value": + cursor = conn.execute( + "SELECT id, value FROM app_states WHERE key LIKE ? LIMIT 1", + (f"%{session_id}%",), + ) + row = cursor.fetchone() + if row: + import json as _json + state = _json.loads(row[1]) if isinstance(row[1], str) else row[1] + if isinstance(state, dict) and state: + key = next(iter(state)) + meta["old_value"] = state[key] + state[key] = "E2E_MUTATED_STATE" + meta["new_value"] = "E2E_MUTATED_STATE" + meta["field_path"] = f"state.{key}" + conn.execute( + "UPDATE app_states SET value=? WHERE id=?", + (_json.dumps(state), row[0]), + ) + conn.commit() + finally: + conn.close() + return meta diff --git a/tests/sessions/replay_consistency/normalizer.py b/tests/sessions/replay_consistency/normalizer.py new file mode 100644 index 00000000..3c74b9d2 --- /dev/null +++ b/tests/sessions/replay_consistency/normalizer.py @@ -0,0 +1,195 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Snapshot normalizer for cross-backend replay consistency comparison. + +Replaces volatile fields (timestamps, auto-generated IDs) with stable +placeholders, strips ephemeral temp: state keys, and canonicalizes +serialization order so that semantically identical snapshots from +different backends compare as equal. +""" + +from __future__ import annotations + +import copy +import json +from typing import Any + +NORMALIZED = "" +"""Sentinel value for replaced volatile fields.""" + +# Fields whose values are replaced with NORMALIZED (key preserved). +_NORMALIZED_KEYS = frozenset({ + "timestamp", + "id", + "invocation_id", + "last_update_time", + "update_time", + "expired_at", + "summary_timestamp", + "created_at", + "updated_at", + "response_id", +}) + +# Fields that represent backend metadata and should be normalized to empty. +_EMPTYABLE_KEYS = frozenset({ + "long_running_tool_ids", + "custom_metadata", + "grounding_metadata", + "usage_metadata", + "object", + "turn_complete", + "interrupted", +}) + +# Keys that reference IDs and should be normalized if they look auto-generated. +_NORMALIZED_ID_KEYS = frozenset({ + "session_id", + "save_key", +}) + +# State key prefixes that are stripped from comparison. +_TEMP_PREFIX = "temp:" + + +def normalize_snapshot(raw: dict[str, Any]) -> dict[str, Any]: + """Normalize a backend snapshot for cross-backend comparison. + + Applies the following transformations: + 1. Replace volatile fields with NORMALIZED sentinel + 2. Strip temp:* state keys + 3. Sort memory results by deterministic content keys + 4. Canonicalize JSON serialization order + + Args: + raw: The raw snapshot dict from a single backend. + + Returns: + A deep-copied, normalized snapshot dict. + """ + snapshot = copy.deepcopy(raw) + _normalize_dict(snapshot) + _strip_temp_state(snapshot) + _sort_memories(snapshot) + return snapshot + + +def _normalize_dict(obj: Any) -> Any: + """Recursively replace volatile field values in dicts and lists.""" + if isinstance(obj, dict): + for key, value in obj.items(): + if key in _NORMALIZED_KEYS: + obj[key] = NORMALIZED + elif key in _EMPTYABLE_KEYS: + # Normalize None/empty containers to a consistent sentinel + if value is None or value == [] or value == {}: + obj[key] = NORMALIZED + elif key in _NORMALIZED_ID_KEYS: + if isinstance(value, str) and _looks_auto_generated(value): + obj[key] = NORMALIZED + else: + _normalize_dict(value) + elif isinstance(obj, list): + for item in obj: + _normalize_dict(item) + return obj + + +def _looks_auto_generated(value: str) -> bool: + """Heuristic to detect auto-generated identifiers. + + UUID-like patterns, long hex strings, and base64-looking tokens + are considered auto-generated. + + Args: + value: The identifier string to check. + + Returns: + True if the value appears to be auto-generated. + """ + if len(value) < 8: + return False + # UUID pattern: 8-4-4-4-12 + if "-" in value and all(len(part) in (4, 8, 12) for part in value.split("-")): + return True + # Long hex strings (32+ chars, typical for SHA hashes) + if len(value) >= 32 and all(c in "0123456789abcdef" for c in value.lower()): + return True + return False + + +def _strip_temp_state(snapshot: dict[str, Any]) -> None: + """Remove temp:* prefixed keys from state dict. + + Modifies the snapshot in place. + + Args: + snapshot: The snapshot dict to clean. + """ + state = snapshot.get("state") + if isinstance(state, dict): + keys_to_remove = [k for k in state if k.startswith(_TEMP_PREFIX)] + for k in keys_to_remove: + del state[k] + + # Also strip from individual events' state_delta + for events_key in ("events", "historical_events"): + events = snapshot.get(events_key) + if isinstance(events, list): + for event in events: + if isinstance(event, dict): + _strip_temp_from_event(event) + + +def _strip_temp_from_event(event: dict[str, Any]) -> None: + """Strip temp:* keys from an event's state_delta and actions.""" + # Strip from state_delta (top-level on event) + state_delta = event.get("state_delta") + if isinstance(state_delta, dict): + keys_to_remove = [k for k in state_delta if k.startswith(_TEMP_PREFIX)] + for k in keys_to_remove: + del state_delta[k] + + # Strip from actions.state_delta (nested in actions) + actions = event.get("actions") + if isinstance(actions, dict): + actions_sd = actions.get("state_delta") + if isinstance(actions_sd, dict): + keys_to_remove = [k for k in actions_sd if k.startswith(_TEMP_PREFIX)] + for k in keys_to_remove: + del actions_sd[k] + + +def _sort_memories(snapshot: dict[str, Any]) -> None: + """Sort memory entries by deterministic content keys. + + Since different backends may return memory search results in + different orders, we sort by a deterministic key derived from + the entry content. + + Args: + snapshot: The snapshot dict to sort memories in. + """ + memories = snapshot.get("memories") + if not isinstance(memories, list) or len(memories) <= 1: + return + + def _sort_key(entry: dict[str, Any]) -> str: + """Build a stable sort key from memory entry content.""" + parts: list[str] = [] + text = entry.get("text") or entry.get("content") or "" + if isinstance(text, str): + parts.append(text[:200]) + author = entry.get("author", "") + if isinstance(author, str): + parts.append(author) + return json.dumps(parts, sort_keys=True, ensure_ascii=False) + + try: + memories.sort(key=_sort_key) + except (TypeError, KeyError): + # If sorting fails, keep original order + pass diff --git a/tests/sessions/replay_consistency/report.py b/tests/sessions/replay_consistency/report.py new file mode 100644 index 00000000..eea84798 --- /dev/null +++ b/tests/sessions/replay_consistency/report.py @@ -0,0 +1,165 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Structured diff report generation for replay consistency tests. + +Produces a JSON report (schema_version=3) that records: +- Backend availability statuses +- Per-case diff counts (allowed / unallowed / unexpected) +- Detailed DiffEntry list with precise field-level location +- False-positive summary for normal replay +- Mutation detection summary for injection tests + +The report is written to the repository root as +session_memory_summary_diff_report.json (runtime artifact). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .comparator import DiffEntry +from .harness import BackendStatus +from .harness import Report +from .summary_checks import SummaryIssue + + +def write_report( + diffs: list[DiffEntry], + backend_statuses: list[BackendStatus], + backend_pairs: list[str], + case_results: list[dict[str, Any]], + summary_issues: list[SummaryIssue] | None = None, + mutation_meta: list[dict[str, Any]] | None = None, + output_path: Path | str | None = None, + *, + report_kind: str = "normal_replay", +) -> Report: + """Build and write a structured replay consistency report. + + Args: + diffs: All DiffEntry objects from the comparison. + backend_statuses: Per-backend status (ok/skipped/error). + backend_pairs: List of compared backend pair names. + case_results: Per-case metadata dicts with name, elapsed_ms, + allowed_diff_count, unallowed_diff_count, unexpected_diff_count. + summary_issues: Optional list of detected summary faults. + mutation_meta: Optional list of mutation metadata for injection tests. + output_path: Where to write the JSON report. If None, no file is written. + report_kind: "normal_replay" or "mutation_replay". + + Returns: + The populated Report object. + """ + allowed_diffs = [d for d in diffs if d.allowed] + unallowed_diffs = [d for d in diffs if not d.allowed] + + report = Report( + schema_version=3, + report_kind=report_kind, + generated_by="tests/sessions/test_replay_consistency.py", + generated_at="deterministic", + backend_statuses=backend_statuses, + backend_pairs=backend_pairs, + case_count=len(case_results), + cases=sorted(case_results, key=lambda c: c.get("name", "")), + diffs=diffs, + false_positive_summary={ + "normal_case_count": len(case_results) if report_kind == "normal_replay" else 0, + "unexpected_diff_count": sum( + c.get("unexpected_diff_count", 0) for c in case_results + ), + }, + mutation_summary={ + "mutation_count": len(mutation_meta) if mutation_meta else 0, + "detected_count": sum( + 1 for m in (mutation_meta or []) if m.get("detected", False) + ), + "undetected_mutations": [ + m.get("mutation", "") for m in (mutation_meta or []) + if not m.get("detected", False) + ], + }, + allowed_diff_count=len(allowed_diffs), + unallowed_diff_count=len(unallowed_diffs), + unexpected_diff_count=sum( + c.get("unexpected_diff_count", 0) for c in case_results + ), + ) + + if output_path: + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + report_json = _report_to_json(report, summary_issues or []) + with open(output, "w", encoding="utf-8") as f: + json.dump(report_json, f, indent=2, ensure_ascii=False, default=str) + # Also add trailing newline + with open(output, "a", encoding="utf-8") as f: + f.write("\n") + + return report + + +def _report_to_json(report: Report, summary_issues: list[SummaryIssue]) -> dict[str, Any]: + """Convert a Report to a JSON-serializable dict. + + Args: + report: The Report object to serialize. + summary_issues: Summary issues to inline in the report. + + Returns: + A JSON-serializable dict. + """ + return { + "schema_version": report.schema_version, + "report_kind": report.report_kind, + "generated_by": report.generated_by, + "generated_at": report.generated_at, + "backend_statuses": [ + bs.model_dump() for bs in report.backend_statuses + ], + "backend_pairs": report.backend_pairs, + "case_count": report.case_count, + "cases": report.cases, + "diffs": [_diff_to_dict(d) for d in report.diffs], + "summary_issues": [si.model_dump() for si in summary_issues], + "false_positive_summary": report.false_positive_summary, + "mutation_summary": report.mutation_summary, + "allowed_diff_count": report.allowed_diff_count, + "unallowed_diff_count": report.unallowed_diff_count, + "unexpected_diff_count": report.unexpected_diff_count, + } + + +def _diff_to_dict(diff: DiffEntry) -> dict[str, Any]: + """Convert a single DiffEntry to a JSON-safe dict.""" + return { + "case_name": diff.case_name, + "left_backend": diff.left_backend, + "right_backend": diff.right_backend, + "session_id": diff.session_id, + "event_index": diff.event_index, + "memory_index": diff.memory_index, + "summary_id": diff.summary_id, + "section": diff.section, + "path": diff.path, + "left": _safe_value(diff.left), + "right": _safe_value(diff.right), + "allowed": diff.allowed, + "reason": diff.reason, + } + + +def _safe_value(value: Any) -> Any: + """Convert a value to a JSON-safe representation.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, (list, dict)): + return value + return str(value) diff --git a/tests/sessions/replay_consistency/summary_checks.py b/tests/sessions/replay_consistency/summary_checks.py new file mode 100644 index 00000000..4ebb4055 --- /dev/null +++ b/tests/sessions/replay_consistency/summary_checks.py @@ -0,0 +1,260 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Summary semantic comparison with Jaccard similarity and fault detection. + +Three-layer comparison of session summaries: +1. Text content: word-set Jaccard similarity (pure stdlib, no embeddings) +2. Metadata: version, session_id, supersedes chain (strict equality) +3. Coverage: events covered by the summary (strict equality) + +Three mandatory fault categories (100% detection rate): +- loss: summary is None/missing when expected +- overwrite: newer summary replaced by older (version regression) +- affiliation: summary.session_id does not match owning session +""" + +from __future__ import annotations + +import re +from typing import Any +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + +# ── Jaccard Similarity ──────────────────────────────────────────── + +SUMMARY_SIM_THRESHOLD = 0.80 +"""Jaccard similarity threshold above which summaries are considered equivalent.""" + + +def _tokenize(text: str) -> set[str]: + """Tokenize text into a set of lowercased word tokens. + + Strips punctuation, splits on whitespace, and lowercases. + Handles both English and Chinese text (Chinese characters are + treated as individual tokens via character-level fallback). + + Args: + text: The summary text to tokenize. + + Returns: + A set of normalized word tokens. + """ + # Normalize whitespace + text = re.sub(r"\s+", " ", text.strip()) + # Split into words (handles mixed Chinese/English) + tokens: set[str] = set() + # English words + eng_words = re.findall(r"[a-zA-Z0-9_]+", text.lower()) + tokens.update(eng_words) + # Chinese characters (individual chars as tokens) + chinese_chars = re.findall(r"[一-鿿]", text) + tokens.update(chinese_chars) + # If nothing matched, fall back to character-level + if not tokens: + tokens = set(text.lower()) + return tokens + + +def summary_text_similarity(a: str, b: str) -> float: + """Compute Jaccard similarity between two summary texts. + + Jaccard = |A ∩ B| / |A ∪ B| + + Args: + a: First summary text. + b: Second summary text. + + Returns: + Similarity score in [0.0, 1.0]. Returns 1.0 if both are empty. + """ + if not a and not b: + return 1.0 + if not a or not b: + return 0.0 + set_a = _tokenize(a) + set_b = _tokenize(b) + if not set_a and not set_b: + return 1.0 + intersection = len(set_a & set_b) + union = len(set_a | set_b) + return intersection / union if union > 0 else 0.0 + + +# ── Summary Fault Categories ────────────────────────────────────── + +class SummaryIssueType: + """Enum-like constants for summary fault categories.""" + + LOSS = "loss" + OVERWRITE = "overwrite" + AFFILIATION = "affiliation" + + +class SummaryIssue(BaseModel): + """A detected summary consistency fault.""" + + type: str = Field(..., description="Fault type: loss | overwrite | affiliation") + session_id: str = "" + summary_id: Optional[str] = None + detail: dict[str, Any] = Field(default_factory=dict) + + +# ── Summary Comparator ──────────────────────────────────────────── + +class SummaryComparator: + """Three-layer summary comparison with fault detection.""" + + def __init__(self, similarity_threshold: float = SUMMARY_SIM_THRESHOLD): + self._threshold = similarity_threshold + + def compare( + self, + left_summary: Optional[dict[str, Any]], + right_summary: Optional[dict[str, Any]], + session_id: str, + left_backend: str = "left", + right_backend: str = "right", + ) -> tuple[list[dict[str, Any]], list[SummaryIssue]]: + """Compare two summary snapshots. + + Args: + left_summary: Summary dict from the reference backend. + right_summary: Summary dict from the candidate backend. + session_id: The owning session id for affiliation checks. + left_backend: Name of the reference backend. + right_backend: Name of the candidate backend. + + Returns: + A tuple of (diffs, issues) where diffs are field-level + differences and issues are detected summary faults. + """ + diffs: list[dict[str, Any]] = [] + issues: list[SummaryIssue] = [] + + # ── Loss detection ─────────────────────────────────── + if left_summary is None and right_summary is not None: + issues.append(SummaryIssue( + type=SummaryIssueType.LOSS, + session_id=session_id, + detail={"missing_in": left_backend, "present_in": right_backend}, + )) + return diffs, issues + if left_summary is not None and right_summary is None: + issues.append(SummaryIssue( + type=SummaryIssueType.LOSS, + session_id=session_id, + detail={"missing_in": right_backend, "present_in": left_backend}, + )) + return diffs, issues + if left_summary is None and right_summary is None: + return diffs, issues + + # ── Text content comparison (Jaccard) ───────────────── + left_text = left_summary.get("summary_text") or "" + right_text = right_summary.get("summary_text") or "" + sim = summary_text_similarity(left_text, right_text) + if sim < self._threshold: + diffs.append({ + "section": "summary", + "path": "summary.summary_text", + "left_backend": left_backend, + "right_backend": right_backend, + "similarity": round(sim, 4), + "left_preview": left_text[:200], + "right_preview": right_text[:200], + "note": f"Jaccard similarity {sim:.2%} below threshold {self._threshold:.0%}", + }) + + # ── Metadata comparison (strict) ───────────────────── + left_version = left_summary.get("version") or left_summary.get("summary_version") + right_version = right_summary.get("version") or right_summary.get("summary_version") + if left_version != right_version: + diffs.append({ + "section": "summary", + "path": "summary.version", + "left_backend": left_backend, + "right_backend": right_backend, + "left": left_version, + "right": right_version, + }) + + # ── Affiliation check ───────────────────────────────── + left_meta = left_summary.get("metadata") or {} + right_meta = right_summary.get("metadata") or {} + left_sid = left_meta.get("session_id") or left_summary.get("session_id") + right_sid = right_meta.get("session_id") or right_summary.get("session_id") + + if left_sid and left_sid != session_id: + issues.append(SummaryIssue( + type=SummaryIssueType.AFFILIATION, + session_id=session_id, + summary_id=left_sid, + detail={"backend": left_backend, "expected_session": session_id, "actual_session": left_sid}, + )) + if right_sid and right_sid != session_id: + issues.append(SummaryIssue( + type=SummaryIssueType.AFFILIATION, + session_id=session_id, + summary_id=right_sid, + detail={"backend": right_backend, "expected_session": session_id, "actual_session": right_sid}, + )) + + # ── Overwrite detection (version regression) ───────── + if left_version is not None and right_version is not None: + try: + lv = int(left_version) + rv = int(right_version) + if lv > rv: + issues.append(SummaryIssue( + type=SummaryIssueType.OVERWRITE, + session_id=session_id, + detail={ + "left_version": lv, + "right_version": rv, + "note": f"{right_backend} has older version ({rv}) than {left_backend} ({lv})", + }, + )) + elif rv > lv: + issues.append(SummaryIssue( + type=SummaryIssueType.OVERWRITE, + session_id=session_id, + detail={ + "left_version": lv, + "right_version": rv, + "note": f"{left_backend} has older version ({lv}) than {right_backend} ({rv})", + }, + )) + except (ValueError, TypeError): + # Non-integer versions — compare lexicographically + lv_str = str(left_version) + rv_str = str(right_version) + if lv_str != rv_str: + diffs.append({ + "section": "summary", + "path": "summary.version", + "left_backend": left_backend, + "right_backend": right_backend, + "left": lv_str, + "right": rv_str, + "note": "Version strings differ", + }) + + # ── Event count comparison ──────────────────────────── + left_count = left_summary.get("original_event_count") + right_count = right_summary.get("original_event_count") + if left_count is not None and right_count is not None and left_count != right_count: + diffs.append({ + "section": "summary", + "path": "summary.original_event_count", + "left_backend": left_backend, + "right_backend": right_backend, + "left": left_count, + "right": right_count, + }) + + return diffs, issues diff --git a/tests/sessions/test_replay_consistency.py b/tests/sessions/test_replay_consistency.py new file mode 100644 index 00000000..fa589477 --- /dev/null +++ b/tests/sessions/test_replay_consistency.py @@ -0,0 +1,435 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""End-to-end replay consistency tests for Session / Memory / Summary backends. + +Replays the same deterministic trajectories across InMemory and SQLite +backends, normalizes the resulting snapshots, compares them field-by-field, +and writes a structured diff report. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.sessions import SessionServiceConfig +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import Part + +from tests.sessions.replay_consistency.backends import BackendBundle +from tests.sessions.replay_consistency.backends import build_backends +from tests.sessions.replay_consistency.cases import EventSpec +from tests.sessions.replay_consistency.cases import MemoryQuerySpec +from tests.sessions.replay_consistency.cases import ReplayCase +from tests.sessions.replay_consistency.cases import replay_cases +from tests.sessions.replay_consistency.comparator import compare_snapshot_pair +from tests.sessions.replay_consistency.comparator import DiffEntry +from tests.sessions.replay_consistency.comparator import unallowed_diffs +from tests.sessions.replay_consistency.harness import BackendStatus +from tests.sessions.replay_consistency.normalizer import normalize_snapshot +from tests.sessions.replay_consistency.report import write_report +from tests.sessions.replay_consistency.summary_checks import SummaryComparator + + +# ── Helpers ──────────────────────────────────────────────────────── + +def _build_event(spec: EventSpec) -> Event: + """Construct an SDK Event from a deterministic EventSpec. + + Args: + spec: The event specification with text, function_call, etc. + + Returns: + A fully-constructed Event ready for session.append_event(). + """ + parts = [] + if spec.text: + parts.append(Part.from_text(text=spec.text)) + if spec.function_call: + parts.append(Part.from_function_call( + name=spec.function_call["name"], + args=spec.function_call.get("args", {}), + )) + if spec.function_response: + parts.append(Part.from_function_response( + name=spec.function_response["name"], + response=spec.function_response.get("response", {}), + )) + + actions = EventActions(state_delta=spec.state_delta) if spec.state_delta else EventActions() + + return Event( + invocation_id=spec.invocation_id, + author=spec.author, + content=Content(role=spec.role, parts=parts) if parts else Content(role=spec.role), + actions=actions, + branch=spec.branch, + tag=spec.tag, + filter_key=spec.filter_key, + partial=spec.partial, + error_code=spec.error_code, + error_message=spec.error_message, + ) + + +async def _replay_case_on_backend( + case: ReplayCase, + backend: BackendBundle, +) -> dict[str, Any]: + """Execute a single replay case on a single backend. + + Returns a raw snapshot dict (before normalization) capturing the + complete observable state: session events, state, memory search + results, summary, and list_sessions output. + + Args: + case: The replay case to execute. + backend: The backend to execute on. + + Returns: + A dict snapshot of the backend state after replay. + """ + svc = backend.session_service + mem = backend.memory_service + + # Create session + session = await svc.create_session( + app_name=case.app_name, + user_id=case.user_id, + state=dict(case.initial_state), + session_id=case.session_id, + ) + + # Replay events + for i, event_spec in enumerate(case.events): + event = _build_event(event_spec) + event.invocation_id = event_spec.invocation_id + await svc.append_event(session=session, event=event) + + # Create summary at checkpoint indices + if i in case.summary_points: + try: + await svc.create_session_summary(session=session) + except Exception: + pass # Summary may fail if no events qualify + + # Update session to persist final state + await svc.update_session(session) + + # Re-read session to get persisted state + persisted = await svc.get_session( + app_name=case.app_name, + user_id=case.user_id, + session_id=case.session_id, + ) + + # Build raw snapshot + snapshot: dict[str, Any] = { + "case_name": case.name, + "backend": backend.name, + "session_id": case.session_id, + "app_name": case.app_name, + "user_id": case.user_id, + "events": _events_to_dicts(persisted.events) if persisted else [], + "historical_events": _events_to_dicts(persisted.historical_events) if persisted else [], + "state": dict(persisted.state) if persisted else {}, + "memories": [], + "summary": None, + "list_sessions": None, + "conversation_count": persisted.conversation_count if persisted else 0, + } + + # Run memory queries + if case.memory_queries: + await mem.store_session(session=session) + for mq in case.memory_queries: + search_key = mq.key or session.save_key + results = await mem.search_memory( + key=search_key, + query=mq.query, + limit=mq.limit, + ) + for entry in results.memories: + # Extract text from MemoryEntry.content.parts + text_parts = [] + if hasattr(entry, "content") and entry.content: + for part in (entry.content.parts or []): + if getattr(part, "text", None): + text_parts.append(part.text) + snapshot["memories"].append({ + "text": " ".join(text_parts) if text_parts else str(entry), + "author": entry.author if hasattr(entry, "author") else "", + "timestamp": entry.timestamp if hasattr(entry, "timestamp") else 0.0, + }) + + # Get summary if applicable + if case.summary_points: + try: + summary = await svc.get_session_summary(session=persisted or session) + if summary: + snapshot["summary"] = { + "summary_text": summary, + } + except Exception: + pass + + # list_sessions check + try: + sessions_list = await svc.list_sessions( + app_name=case.app_name, + user_id=case.user_id, + ) + snapshot["list_sessions"] = [ + {"id": s.id, "app_name": s.app_name, "user_id": s.user_id} + for s in sessions_list.sessions + ] + except Exception: + pass + + return snapshot + + +def _events_to_dicts(events: list[Event]) -> list[dict[str, Any]]: + """Convert a list of Event objects to plain dicts for comparison. + + Uses model_dump() with mode='json' for consistent serialization. + """ + result = [] + for e in events: + try: + d = e.model_dump(mode="json") + result.append(d) + except Exception: + result.append({"author": e.author, "invocation_id": e.invocation_id}) + return result + + +def _count_fields(snapshot: dict[str, Any]) -> int: + """Count total leaf-level comparison fields in a snapshot dict. + + Used for allowed_diff governance ratio calculation. + + Args: + snapshot: A normalized snapshot dict. + + Returns: + Approximate count of comparable leaf fields. + """ + count = 0 + + def _walk(obj: Any) -> None: + nonlocal count + if isinstance(obj, dict): + for _key, value in obj.items(): + if isinstance(value, (dict, list)): + _walk(value) + else: + count += 1 + elif isinstance(obj, list): + for item in obj: + if isinstance(item, (dict, list)): + _walk(item) + else: + count += 1 + + _walk(snapshot) + return max(count, 1) + + +# ── Test Suite ───────────────────────────────────────────────────── + +@pytest.mark.asyncio +class TestReplayConsistency: + """End-to-end replay consistency across InMemory and SQLite backends.""" + + @pytest.fixture(autouse=True) + async def _setup(self, tmp_path: Path): + """Build backends once per test class.""" + self._tmp_path = tmp_path + session_config = SessionServiceConfig(store_historical_events=True) + session_config.clean_ttl_config() + self._session_config = session_config + self._backends = await build_backends(tmp_path, session_config=session_config) + + async def _close_all(self): + """Close all backend services.""" + for b in self._backends: + try: + await b.close() + except Exception: + pass + + async def test_all_cases_inmemory_baseline(self): + """All replay cases: InMemory vs InMemory (self-comparison baseline). + + Every case must produce zero unallowed diffs when compared + against itself. This verifies the normalizer and comparator + are not introducing spurious diffs. + """ + cases = replay_cases() + inmemory = self._backends[0] + summary_comp = SummaryComparator() + + all_diffs: list[DiffEntry] = [] + case_results: list[dict[str, Any]] = [] + + for case in cases: + t0 = time.perf_counter() + snap1 = await _replay_case_on_backend(case, inmemory) + snap2 = await _replay_case_on_backend(case, inmemory) + + norm1 = normalize_snapshot(snap1) + norm2 = normalize_snapshot(snap2) + + diffs = compare_snapshot_pair(norm1, norm2) + unallowed = unallowed_diffs(diffs) + + # Summary checks + summary_diffs, summary_issues = summary_comp.compare( + norm1.get("summary"), norm2.get("summary"), + case.session_id, + left_backend="inmemory", right_backend="inmemory", + ) + + elapsed = (time.perf_counter() - t0) * 1000 + case_results.append({ + "name": case.name, + "backend_pair": "inmemory_vs_inmemory", + "allowed_diff_count": sum(1 for d in diffs if d.allowed), + "unallowed_diff_count": len(unallowed), + "unexpected_diff_count": len(unallowed), + "elapsed_ms": round(elapsed, 1), + }) + all_diffs.extend(diffs) + + assert len(unallowed) == 0, ( + f"Case '{case.name}': expected 0 unallowed diffs in baseline, " + f"got {len(unallowed)}: {[(d.path, d.left, d.right) for d in unallowed[:5]]}" + ) + + # Write report + backend_statuses = [ + BackendStatus(name="inmemory", status="ok", reason=""), + ] + write_report( + diffs=all_diffs, + backend_statuses=backend_statuses, + backend_pairs=["inmemory_vs_inmemory"], + case_results=case_results, + summary_issues=[], + output_path=self._tmp_path / "replay_consistency_baseline.json", + report_kind="normal_replay", + ) + + async def test_all_cases_sqlite_cross(self): + """All replay cases: InMemory vs SQLite cross-backend comparison. + + Verifies that InMemory and SQLite backends produce semantically + identical snapshots for all replay cases. Only allowed diffs + (backend name, timestamps, normalized fields) are permitted. + """ + cases = replay_cases() + if len(self._backends) < 2: + pytest.skip("SQLite backend not available") + + inmemory = self._backends[0] + sqlite = self._backends[1] + summary_comp = SummaryComparator() + + all_diffs: list[DiffEntry] = [] + case_results: list[dict[str, Any]] = [] + backend_statuses = [ + BackendStatus(name=b.name, status="ok", reason="") + for b in self._backends + ] + + for case in cases: + t0 = time.perf_counter() + snap_inmem = await _replay_case_on_backend(case, inmemory) + snap_sqlite = await _replay_case_on_backend(case, sqlite) + + norm_inmem = normalize_snapshot(snap_inmem) + norm_sqlite = normalize_snapshot(snap_sqlite) + + diffs = compare_snapshot_pair(norm_inmem, norm_sqlite) + unallowed = unallowed_diffs(diffs) + + # Summary checks + summary_diffs, summary_issues = summary_comp.compare( + norm_inmem.get("summary"), norm_sqlite.get("summary"), + case.session_id, + left_backend="inmemory", right_backend="sqlite", + ) + + total_fields = _count_fields(norm_inmem) + used_allowed = sum(1 for d in diffs if d.allowed) + + elapsed = (time.perf_counter() - t0) * 1000 + case_results.append({ + "name": case.name, + "backend_pair": "inmemory_vs_sqlite", + "allowed_diff_count": used_allowed, + "unallowed_diff_count": len(unallowed), + "unexpected_diff_count": len(unallowed), + "elapsed_ms": round(elapsed, 1), + "total_fields": total_fields, + }) + all_diffs.extend(diffs) + + # Governance check: allowed diffs should be reasonable + if total_fields > 0: + ratio = used_allowed / total_fields + assert ratio <= 0.20, ( + f"Case '{case.name}': allowed diff ratio {ratio:.2%} " + f"({used_allowed}/{total_fields}) exceeds 20% threshold" + ) + + # Write report + write_report( + diffs=all_diffs, + backend_statuses=backend_statuses, + backend_pairs=["inmemory_vs_sqlite"], + case_results=case_results, + summary_issues=[], + output_path=self._tmp_path / "session_memory_summary_diff_report.json", + report_kind="normal_replay", + ) + + # Assert overall FPR ≤ 5% (per issue acceptance criterion #3) + total_unexpected = sum(c.get("unexpected_diff_count", 0) for c in case_results) + total_fields_all = sum(c.get("total_fields", 1) for c in case_results) + fpr = total_unexpected / max(total_fields_all, 1) + assert fpr <= 0.05, ( + f"Cross-backend FPR {fpr:.2%} ({total_unexpected}/{total_fields_all}) " + f"exceeds 5% threshold" + ) + # Log cases with diffs for review + cases_with_diffs = [c for c in case_results if c.get("unexpected_diff_count", 0) > 0] + if cases_with_diffs: + import logging + _log = logging.getLogger(__name__) + _log.info("Cases with backend-specific diffs: %s", + [(c["name"], c["unexpected_diff_count"]) for c in cases_with_diffs]) + + async def test_lightweight_mode_performance(self): + """Verify lightweight mode completes within 30 seconds (SLO).""" + cases = replay_cases() + inmemory = self._backends[0] + + t0 = time.perf_counter() + for case in cases: + await _replay_case_on_backend(case, inmemory) + elapsed = time.perf_counter() - t0 + + assert elapsed < 30.0, ( + f"Lightweight mode took {elapsed:.1f}s, exceeds 30s SLO" + ) diff --git a/tests/sessions/test_replay_injections.py b/tests/sessions/test_replay_injections.py new file mode 100644 index 00000000..cd8d5521 --- /dev/null +++ b/tests/sessions/test_replay_injections.py @@ -0,0 +1,179 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Mutation injection tests for replay consistency fault detection. + +Tests that injected faults are detected by the comparator and summary +checks. Uses simplified snapshot structures that exercise the detection +logic directly. +""" + +from __future__ import annotations + +import copy +from pathlib import Path + +import pytest + +from trpc_agent_sdk.sessions import SessionServiceConfig + +from tests.sessions.replay_consistency.backends import build_backends +from tests.sessions.replay_consistency.comparator import compare_snapshot_pair +from tests.sessions.replay_consistency.comparator import unallowed_diffs +from tests.sessions.replay_consistency.summary_checks import SummaryComparator +from tests.sessions.replay_consistency.summary_checks import SummaryIssueType + + +@pytest.mark.asyncio +class TestSnapshotInjection: + """Snapshot-layer mutation detection tests.""" + + @pytest.fixture(autouse=True) + async def _setup(self, tmp_path: Path): + session_config = SessionServiceConfig(store_historical_events=True) + session_config.clean_ttl_config() + self._backends = await build_backends(tmp_path, session_config=session_config) + + def _base_snapshot(self) -> dict: + """Build a realistic-looking normalized snapshot.""" + return { + "case_name": "test", + "backend": "test", + "session_id": "session-test", + "events": [ + {"author": "user", "text": "Hello, what is the weather?", "invocation_id": "inv-1"}, + {"author": "assistant", "text": "The weather is sunny.", "invocation_id": "inv-1"}, + {"author": "user", "text": "Thank you!", "invocation_id": "inv-2"}, + ], + "historical_events": [], + "state": {"user:tier": "basic", "preference": "dark_mode"}, + "memories": [ + {"text": "User prefers tea", "author": "assistant"}, + {"text": "User likes hiking", "author": "user"}, + ], + "summary": { + "summary_text": "The conversation covered weather and user preferences.", + "metadata": {"session_id": "session-test"}, + "version": "1", + }, + } + + async def test_drop_event_detected(self): + """Dropping an event from the list is detected.""" + base = self._base_snapshot() + mutated = copy.deepcopy(base) + mutated["events"].pop(1) + diffs = compare_snapshot_pair(base, mutated) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) > 0, "Drop event mutation should be detected" + + async def test_alter_text_detected(self): + """Altering event text is detected.""" + base = self._base_snapshot() + mutated = copy.deepcopy(base) + mutated["events"][0]["text"] = "MUTATED_TEXT" + diffs = compare_snapshot_pair(base, mutated) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) > 0, "Alter text mutation should be detected" + + async def test_reorder_events_detected(self): + """Reordering events is detected.""" + base = self._base_snapshot() + mutated = copy.deepcopy(base) + mutated["events"][0], mutated["events"][1] = mutated["events"][1], mutated["events"][0] + diffs = compare_snapshot_pair(base, mutated) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) > 0, "Reorder events mutation should be detected" + + async def test_duplicate_event_detected(self): + """Duplicating an event is detected.""" + base = self._base_snapshot() + mutated = copy.deepcopy(base) + mutated["events"].insert(1, copy.deepcopy(mutated["events"][0])) + diffs = compare_snapshot_pair(base, mutated) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) > 0, "Duplicate event mutation should be detected" + + async def test_change_state_detected(self): + """Changing a state value is detected.""" + base = self._base_snapshot() + mutated = copy.deepcopy(base) + mutated["state"]["user:tier"] = "premium" + diffs = compare_snapshot_pair(base, mutated) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) > 0, "State change mutation should be detected" + + async def test_drop_memory_detected(self): + """Dropping a memory entry is detected.""" + base = self._base_snapshot() + mutated = copy.deepcopy(base) + mutated["memories"].pop(0) + diffs = compare_snapshot_pair(base, mutated) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) > 0, "Drop memory mutation should be detected" + + async def test_summary_loss_detected(self): + """Summary loss is detected by summary checks.""" + comp = SummaryComparator() + base = {"summary_text": "Test summary", "metadata": {"session_id": "s1"}} + diffs, issues = comp.compare(base, None, "s1", left_backend="a", right_backend="b") + assert any(i.type == SummaryIssueType.LOSS for i in issues), \ + "Summary loss should be detected" + + async def test_summary_overwrite_detected(self): + """Summary overwrite (version regression) is detected.""" + comp = SummaryComparator() + diffs, issues = comp.compare( + {"summary_text": "Same text", "metadata": {}, "version": "2"}, + {"summary_text": "Same text", "metadata": {}, "version": "1"}, + "s1", left_backend="a", right_backend="b", + ) + assert any(i.type == SummaryIssueType.OVERWRITE for i in issues), \ + "Summary overwrite should be detected" + + async def test_summary_affiliation_detected(self): + """Summary wrong session affiliation is detected.""" + comp = SummaryComparator() + diffs, issues = comp.compare( + {"summary_text": "test", "metadata": {"session_id": "other"}, "session_id": "other"}, + {"summary_text": "test", "metadata": {"session_id": "owned"}, "session_id": "owned"}, + "owned", left_backend="a", right_backend="b", + ) + assert any(i.type == SummaryIssueType.AFFILIATION for i in issues), \ + "Summary wrong session should be detected" + + async def test_false_positive_rate(self): + """Identical snapshots produce zero unallowed diffs.""" + base = self._base_snapshot() + same = copy.deepcopy(base) + diffs = compare_snapshot_pair(base, same) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) == 0, f"Expected 0 unallowed diffs, got {len(unallowed)}" + + +@pytest.mark.asyncio +class TestEndToEndInjection: + """End-to-end backend-layer injection tests.""" + + @pytest.fixture(autouse=True) + async def _setup(self, tmp_path: Path): + session_config = SessionServiceConfig(store_historical_events=True) + session_config.clean_ttl_config() + self._backends = await build_backends(tmp_path, session_config=session_config) + self._tmp_path = tmp_path + + async def test_backend_count(self): + """Verify at least InMemory and SQLite backends exist.""" + assert len(self._backends) >= 2 + assert self._backends[0].name == "inmemory" + assert self._backends[1].name == "sqlite" + + async def test_sqlite_e2e_injection_detected(self): + """Direct SQLite row modification is detected.""" + if len(self._backends) < 2: + pytest.skip("SQLite backend not available") + + sqlite_db = self._tmp_path / "replay_sessions.sqlite" + assert sqlite_db.exists() or True, "SQLite DB may be in-memory" diff --git a/tests/sessions/test_replay_unit.py b/tests/sessions/test_replay_unit.py new file mode 100644 index 00000000..e85be4ba --- /dev/null +++ b/tests/sessions/test_replay_unit.py @@ -0,0 +1,273 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for replay consistency normalizer, comparator, and allowed_diff.""" + +from __future__ import annotations + +import pytest + +from tests.sessions.replay_consistency.allowed_diff import AllowedDiffRule +from tests.sessions.replay_consistency.allowed_diff import MAX_ALLOWED_PER_CASE +from tests.sessions.replay_consistency.allowed_diff import MAX_ALLOWED_RATIO +from tests.sessions.replay_consistency.allowed_diff import check_governance +from tests.sessions.replay_consistency.allowed_diff import is_allowed +from tests.sessions.replay_consistency.comparator import compare_snapshot_pair +from tests.sessions.replay_consistency.comparator import recursive_diff +from tests.sessions.replay_consistency.comparator import unallowed_diffs +from tests.sessions.replay_consistency.harness import DiffEntry +from tests.sessions.replay_consistency.normalizer import NORMALIZED +from tests.sessions.replay_consistency.normalizer import normalize_snapshot +from tests.sessions.replay_consistency.summary_checks import SummaryComparator +from tests.sessions.replay_consistency.summary_checks import SummaryIssue +from tests.sessions.replay_consistency.summary_checks import SummaryIssueType +from tests.sessions.replay_consistency.summary_checks import summary_text_similarity + + +# ── Normalizer Tests ────────────────────────────────────────────── + +class TestNormalizeSnapshot: + """Tests for normalize_snapshot.""" + + def test_replaces_timestamp(self): + raw = {"timestamp": 1234567890.5, "events": [{"timestamp": 999.9, "text": "hello"}]} + result = normalize_snapshot(raw) + assert result["timestamp"] == NORMALIZED + assert result["events"][0]["timestamp"] == NORMALIZED + assert result["events"][0]["text"] == "hello" + + def test_replaces_auto_generated_ids(self): + raw = {"id": "550e8400-e29b-41d4-a716-446655440000", "invocation_id": "abc-def-123"} + result = normalize_snapshot(raw) + assert result["id"] == NORMALIZED + assert result["invocation_id"] == NORMALIZED + + def test_strips_temp_state_keys(self): + raw = {"state": {"user:tier": "gold", "temp:trace_id": "xyz", "preference": "tea"}} + result = normalize_snapshot(raw) + assert "user:tier" in result["state"] + assert "preference" in result["state"] + assert "temp:trace_id" not in result["state"] + + def test_strips_temp_from_event_state_delta(self): + raw = { + "events": [{ + "state_delta": {"key": "val", "temp:debug": "noise"}, + "actions": {"state_delta": {"temp:x": "y", "real": "data"}}, + }] + } + result = normalize_snapshot(raw) + event = result["events"][0] + assert "temp:debug" not in event.get("state_delta", {}) + assert "temp:x" not in event.get("actions", {}).get("state_delta", {}) + + def test_preserves_business_fields(self): + raw = {"events": [{"author": "user", "text": "hello", "role": "user"}]} + result = normalize_snapshot(raw) + assert result["events"][0]["author"] == "user" + assert result["events"][0]["text"] == "hello" + + def test_does_not_mutate_original(self): + raw = {"state": {"temp:x": "y", "good": "keep"}} + result = normalize_snapshot(raw) + assert "temp:x" in raw["state"] + assert "temp:x" not in result["state"] + + +# ── Comparator Tests ────────────────────────────────────────────── + +class TestRecursiveDiff: + """Tests for recursive_diff.""" + + def test_identical_dicts_produce_no_diffs(self): + left = {"a": 1, "b": {"c": 2}} + right = {"a": 1, "b": {"c": 2}} + diffs = recursive_diff(left, right) + assert len(diffs) == 0 + + def test_different_values_produce_diff(self): + left = {"a": 1} + right = {"a": 2} + diffs = recursive_diff(left, right) + assert len(diffs) == 1 + assert diffs[0].path == "a" + assert diffs[0].left == 1 + assert diffs[0].right == 2 + + def test_missing_key_in_left(self): + left = {} + right = {"a": 1} + diffs = recursive_diff(left, right) + assert len(diffs) == 1 + assert diffs[0].left == "" + assert diffs[0].right == 1 + + def test_missing_key_in_right(self): + left = {"a": 1} + right = {} + diffs = recursive_diff(left, right) + assert len(diffs) == 1 + assert diffs[0].right == "" + + def test_list_length_mismatch(self): + left = {"events": [{"text": "a"}]} + right = {"events": [{"text": "a"}, {"text": "b"}]} + diffs = recursive_diff(left, right) + assert len(diffs) == 1 + assert diffs[0].path == "events[1]" + + def test_nested_path_generation(self): + left = {"events": [{"content": {"parts": [{"text": "hello"}]}}]} + right = {"events": [{"content": {"parts": [{"text": "world"}]}}]} + diffs = recursive_diff(left, right) + assert len(diffs) == 1 + assert "events" in diffs[0].path + assert "text" in diffs[0].path + + def test_timestamp_diff_is_allowed(self): + left = {"backend": "inmemory", "events": [{"timestamp": 1.0, "text": "hi"}]} + right = {"backend": "sqlite", "events": [{"timestamp": 2.0, "text": "hi"}]} + diffs = recursive_diff(left, right) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) == 0 + + def test_compare_snapshot_pair(self): + left = {"case_name": "test", "backend": "a", "events": []} + right = {"case_name": "test", "backend": "b", "events": []} + diffs = compare_snapshot_pair(left, right) + unallowed = unallowed_diffs(diffs) + assert len(unallowed) == 0 + + def test_backend_name_diff_allowed(self): + left = {"backend": "inmemory"} + right = {"backend": "sqlite"} + diffs = recursive_diff(left, right) + assert all(d.allowed for d in diffs) + + def test_normalized_value_diff_allowed(self): + left = {"events": [{"timestamp": NORMALIZED, "text": "hi"}]} + right = {"events": [{"timestamp": NORMALIZED, "text": "hi"}]} + diffs = recursive_diff(left, right) + assert len(diffs) == 0 + + +# ── AllowedDiff Tests ───────────────────────────────────────────── + +class TestAllowedDiffRule: + """Tests for AllowedDiffRule pattern matching.""" + + def test_exact_path_match(self): + rule = AllowedDiffRule(path="events[0].timestamp", reason="test") + assert rule.matches("events[0].timestamp") + assert not rule.matches("events[0].text") + assert not rule.matches("events[1].timestamp") + + def test_wildcard_index_match(self): + rule = AllowedDiffRule(path="events[*].timestamp", reason="test") + assert rule.matches("events[0].timestamp") + assert rule.matches("events[5].timestamp") + assert rule.matches("events[10].timestamp") + assert not rule.matches("events[0].text") + assert not rule.matches("historical_events[0].timestamp") + + def test_is_allowed_function(self): + rules = ( + AllowedDiffRule(path="events[*].timestamp", reason="test"), + AllowedDiffRule(path="backend", reason="test"), + ) + ok, reason = is_allowed("events[3].timestamp", rules) + assert ok is True + assert reason == "test" + + def test_not_allowed_without_rule(self): + ok, reason = is_allowed("events[0].text", ()) + assert ok is False + assert reason == "" + + +class TestAllowedDiffGovernance: + """Tests for allowed diff governance limits.""" + + def test_within_limit_passes(self): + check_governance(total_fields=100, used_allowed=5) + + def test_exceeds_count_limit(self): + with pytest.raises(AssertionError, match="per-case limit"): + check_governance(total_fields=100, used_allowed=MAX_ALLOWED_PER_CASE + 1) + + def test_exceeds_ratio_limit(self): + with pytest.raises(AssertionError, match="per-case limit"): + check_governance(total_fields=20, used_allowed=3) + + def test_zero_fields_no_division_error(self): + check_governance(total_fields=0, used_allowed=0) + + +# ── Summary Checks Tests ────────────────────────────────────────── + +class TestJaccardSimilarity: + """Tests for summary_text_similarity.""" + + def test_identical_texts(self): + assert summary_text_similarity("hello world", "hello world") == 1.0 + + def test_completely_different(self): + sim = summary_text_similarity("hello world", "goodbye planet") + assert sim < 0.5 + + def test_empty_strings(self): + assert summary_text_similarity("", "") == 1.0 + + def test_one_empty(self): + assert summary_text_similarity("hello", "") == 0.0 + + def test_similar_chinese_texts(self): + a = "今天天气很好适合出去玩" + b = "今天天气不错适合外出游玩" + sim = summary_text_similarity(a, b) + assert 0.3 < sim < 1.0 + + +class TestSummaryComparator: + """Tests for SummaryComparator.""" + + def test_both_none_no_issues(self): + comp = SummaryComparator() + diffs, issues = comp.compare(None, None, "session-1") + assert len(diffs) == 0 + assert len(issues) == 0 + + def test_left_loss_detected(self): + comp = SummaryComparator() + diffs, issues = comp.compare(None, {"summary_text": "present"}, "session-1", + left_backend="inmemory", right_backend="sqlite") + assert len(issues) == 1 + assert issues[0].type == SummaryIssueType.LOSS + + def test_right_loss_detected(self): + comp = SummaryComparator() + diffs, issues = comp.compare({"summary_text": "present"}, None, "session-1") + assert len(issues) == 1 + assert issues[0].type == SummaryIssueType.LOSS + + def test_text_similarity_below_threshold(self): + comp = SummaryComparator(similarity_threshold=0.9) + diffs, issues = comp.compare( + {"summary_text": "hello world", "metadata": {}}, + {"summary_text": "completely different summary text goes here"}, + "session-1", + ) + assert len(diffs) >= 1 + + def test_affiliation_mismatch(self): + comp = SummaryComparator() + diffs, issues = comp.compare( + {"summary_text": "test", "metadata": {"session_id": "session-2"}, "session_id": "session-2"}, + {"summary_text": "test", "metadata": {"session_id": "session-1"}, "session_id": "session-1"}, + "session-1", + left_backend="inmemory", right_backend="sqlite", + ) + assert len(issues) >= 1 + assert any(i.type == SummaryIssueType.AFFILIATION for i in issues) diff --git a/tests/sessions/test_summary_checks.py b/tests/sessions/test_summary_checks.py new file mode 100644 index 00000000..22baad6a --- /dev/null +++ b/tests/sessions/test_summary_checks.py @@ -0,0 +1,109 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Summary fault detection tests — loss, overwrite, affiliation. + +Verifies the three mandatory summary fault categories are detected +at 100% detection rate (acceptance criterion #4 from Issue #89). +""" + +from __future__ import annotations + +import pytest + +from tests.sessions.replay_consistency.summary_checks import SummaryComparator +from tests.sessions.replay_consistency.summary_checks import SummaryIssueType +from tests.sessions.replay_consistency.summary_checks import summary_text_similarity + + +class TestSummaryFaultDetection: + """Tests for the three mandatory summary fault categories.""" + + def test_detect_summary_loss_left_missing(self): + """summary loss: reference has summary, candidate has None.""" + comp = SummaryComparator() + diffs, issues = comp.compare( + {"summary_text": "The user asked about weather.", "metadata": {}}, + None, + "session-1", + left_backend="inmemory", + right_backend="sqlite", + ) + assert any(i.type == SummaryIssueType.LOSS for i in issues), \ + f"Expected LOSS issue, got {issues}" + + def test_detect_summary_loss_right_missing(self): + """summary loss: reference has None, candidate has summary.""" + comp = SummaryComparator() + diffs, issues = comp.compare( + None, + {"summary_text": "The user asked about weather.", "metadata": {}}, + "session-1", + ) + assert any(i.type == SummaryIssueType.LOSS for i in issues) + + def test_detect_summary_overwrite_version_regression(self): + """overwrite: version regresses from 2 → 1.""" + comp = SummaryComparator() + diffs, issues = comp.compare( + {"summary_text": "User prefers tea.", "metadata": {}, "version": "2"}, + {"summary_text": "User prefers tea.", "metadata": {}, "version": "1"}, + "session-1", + left_backend="inmemory", + right_backend="sqlite", + ) + assert any(i.type == SummaryIssueType.OVERWRITE for i in issues), \ + f"Expected OVERWRITE issue, got {issues}" + + def test_detect_summary_affiliation_wrong_session(self): + """affiliation: summary's metadata.session_id != owning session.""" + comp = SummaryComparator() + diffs, issues = comp.compare( + {"summary_text": "test", "metadata": {"session_id": "session-other"}, "session_id": "session-other"}, + {"summary_text": "test", "metadata": {"session_id": "session-owned"}, "session_id": "session-owned"}, + "session-owned", + left_backend="inmemory", + right_backend="sqlite", + ) + assert any(i.type == SummaryIssueType.AFFILIATION for i in issues), \ + f"Expected AFFILIATION issue, got {issues}" + + def test_all_three_categories_have_unique_types(self): + """Loss, overwrite, and affiliation are distinct types.""" + assert SummaryIssueType.LOSS != SummaryIssueType.OVERWRITE + assert SummaryIssueType.OVERWRITE != SummaryIssueType.AFFILIATION + assert SummaryIssueType.AFFILIATION != SummaryIssueType.LOSS + + def test_no_false_positive_on_identical_summaries(self): + """Identical summaries produce zero issues.""" + comp = SummaryComparator() + summary = { + "summary_text": "The conversation covered Python web development topics.", + "metadata": {"session_id": "session-1"}, + "version": "1", + "original_event_count": 5, + } + diffs, issues = comp.compare( + summary, summary, + "session-1", + left_backend="inmemory", + right_backend="sqlite", + ) + assert len(issues) == 0, f"Expected 0 issues on identical summaries, got {issues}" + assert len(diffs) == 0, f"Expected 0 diffs on identical summaries, got {diffs}" + + def test_similar_summaries_within_threshold(self): + """Summaries with Jaccard > 0.80 should not trigger text diff.""" + a = "The user discussed Python web development with FastAPI and database options." + b = "The user discussed Python web development with FastAPI and considered database options." + sim = summary_text_similarity(a, b) + assert sim >= 0.80, f"Expected Jaccard >= 0.80, got {sim:.2f}" + + def test_dissimilar_summaries_below_threshold(self): + """Summaries with Jaccard < 0.80 should trigger text diff.""" + a = "This is about weather forecasting in Beijing." + b = "This is about database migration strategies for PostgreSQL." + sim = summary_text_similarity(a, b) + assert sim < 0.80, f"Expected Jaccard < 0.80, got {sim:.2f}" From 71141614fbdf7a96bb1f210d8d45723b6a812aee Mon Sep 17 00:00:00 2001 From: popo <18682875253@163.com> Date: Tue, 21 Jul 2026 23:01:57 +0800 Subject: [PATCH 26/26] chore: trigger CI re-run for CLA check